This commit is contained in:
Matthew Wise 2023-08-25 21:37:54 -05:00 committed by mawise
parent c2054e21a8
commit b3979cb8fe
8 changed files with 337 additions and 68 deletions

View File

@ -1,6 +1,49 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
class JsonError < StandardError
attr_accessor :error, :error_description, :status, :options
def initialize(error, error_description, status, options={})
@error = error
@error_description = error_description
@status = status
@options = options
end
def to_json
j = { error: @error, error_description: @error_description}
options.each {|k,v| j[k]=v}
return j
end
end
before_action :configure_permitted_parameters, if: :devise_controller? before_action :configure_permitted_parameters, if: :devise_controller?
rescue_from JsonError, with: :json_error_handler
def json_error_handler(json_error)
render json: json_error.to_json, status: json_error.status
end
def set_auth_token
token = nil
if params[:access_token]
token = IndieAuthToken.find_by(access_token: params[:access_token])
elsif request.headers["Authorization"]
t = request.headers["Authorization"]
if t.start_with? "Bearer "
t2 = t.split("Bearer ",2).last
token = IndieAuthToken.find_by(access_token: t2)
end
end
if token.nil?
raise JsonError.new("unauthorized", "This action requires an authentication token", 401)
end
@token = token
end
def validate_scope(scope)
unless @token.scope.split(" ").include? scope
raise JsonError.new("insufficient_scope","The provided authentication token does not include the scope: '#{scope}' which is required for this action", 403, scope: scope)
end
end
def self.get_settings def self.get_settings
return Setting.first || Setting.new return Setting.first || Setting.new

View File

@ -75,9 +75,9 @@ class FeedsController < ApplicationController
end end
def destroy def destroy
@feed = Feed.find(params[:id]) @feed = current_user.feeds.find(params[:id])
feed_name = @feed.name
if ( (!@feed.nil?) and (current_user == @feed.user) ) if ( (!@feed.nil?) and (current_user == @feed.user) )
feed_name = @feed.name
@feed.destroy! @feed.destroy!
flash[:notice] = "You have removed #{feed_name} from your feeds" flash[:notice] = "You have removed #{feed_name} from your feeds"
else else

View File

@ -1,26 +1,11 @@
class MicropubController < ApplicationController class MicropubController < ApplicationController
class MicropubError < StandardError
attr_accessor :error, :error_description, :status, :options
def initialize(error, error_description, status, options={})
@error = error
@error_description = error_description
@status = status
@options = options
end
def to_json
j = { error: @error, error_description: @error_description}
options.each {|k,v| j[k]=v}
return j
end
end
rescue_from MicropubError, with: :micropub_error_handler
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
before_action :set_auth_token before_action :set_auth_token
def query def query
unless params[:q] unless params[:q]
raise MicropubError.new("invalid_request","This action requires a query to be specified with the 'q' parameter",400) raise JsonError.new("invalid_request","This action requires a query to be specified with the 'q' parameter",400)
end end
if params[:q] == "config" if params[:q] == "config"
render json: { render json: {
@ -47,16 +32,17 @@ class MicropubController < ApplicationController
}, status: 200 }, status: 200
end end
else else
raise MicropubError.new("invalid_request","Unrecognized 'q' parameter: #{params[:q]}",400) raise JsonError.new("invalid_request","Unrecognized 'q' parameter: #{params[:q]}",400)
end end
end end
# micropub overloads posts to a single endpoint for create/update/delete # micropub overloads POSTs to a single endpoint for create/update/delete
# so this method handles all three # so this method handles all three
def create def create
validate_scope("create") validate_scope("create")
if (params[:action]) and (params[:action] == "update") raw_action = request.request_parameters["action"]
if (raw_action) and (raw_action == "update")
validate_scope("update") validate_scope("update")
if params[:url] if params[:url]
post = post_from_url(params[:url]) post = post_from_url(params[:url])
@ -73,60 +59,60 @@ class MicropubController < ApplicationController
end end
end end
if params[:add] if params[:add]
raise MicropubError.new( raise JsonError.new(
"invalid_request", "invalid_request",
"None of the poperties this server supports (content, published) allow multiple values", "None of the poperties this server supports (content, published) allow multiple values",
400) 400)
end end
if params[:delete] if params[:delete]
raise MicropubError.new( raise JsonError.new(
"invalid_request", "invalid_request",
"All properties are required (content, published), none can be deleted", "All properties are required (content, published), none can be deleted",
400) 400)
end end
rescue => e rescue => e
raise MicropubError.new( raise JsonError.new(
"invalid_request", "invalid_request",
"#{e.class.name}: #{e.message}", "#{e.class.name}: #{e.message}",
400) 400)
end ## end begin/rescue end ## end begin/rescue
else # no url param else # no url param
raise MiropubError.new( raise JsonError.new(
"invalid_request", "invalid_request",
"Request includes an action of 'update' but is missing the URL of the post to update", "Request includes an action of 'update' but is missing the URL of the post to update",
400) 400)
return return
end end
elsif (params[:action]) and (params[:action] == "delete") elsif (raw_action) and (raw_action == "delete")
validate_scope("delete") validate_scope("delete")
if params[:url] if params[:url]
post = post_from_url(params[:url]) post = post_from_url(params[:url])
begin begin
post.destroy! post.destroy!
rescue => e rescue => e
raise MicropubError.new( raise JsonError.new(
"server_error", "server_error",
"#{e.class.name}: #{e.message}", "#{e.class.name}: #{e.message}",
500) 500)
end end
head 204 # no content from successful deletion head 204 # no content from successful deletion
else else
raise MicropubError.new("invalid_request", raise JsonError.new("invalid_request",
"Request includes an action of 'delete' but is missing the URL of the post to delete", "Request includes an action of 'delete' but is missing the URL of the post to delete",
400) 400)
end end
elsif (params[:action] and params[:action]=="create") or (!params[:action]) # no action should be a create elsif (raw_action and raw_action=="create") or (raw_action.nil?) # no action should be a create
@post = post_from_params(params) @post = post_from_params(params)
@post.author = @token.user @post.author = @token.user
@post.save! @post.save!
response.set_header("Location", url_for(@post)) response.set_header("Location", url_for(@post))
head 201 #created head 201 #created
elsif (params[:action]) # unknown action elsif (raw_action) # unknown action
raise MicropubError.new("invalid_request", raise JsonError.new("invalid_request",
"Request includes an unrecognized action of '#{params[:action]}', only 'update' and 'delete' are recognized", "Request includes an unrecognized action of '#{raw_action}', only 'update' and 'delete' are recognized",
400, request_json: params) 400, request_json: params)
else else
raise MicropubError.new("invalid_request", raise JsonError.new("invalid_request",
"Unable to parse request, you shouldn't really be able to get to this error", "Unable to parse request, you shouldn't really be able to get to this error",
400, request_json: params) 400, request_json: params)
end end
@ -135,48 +121,20 @@ class MicropubController < ApplicationController
def media def media
validate_scope("media") validate_scope("media")
if params[:file].nil? if params[:file].nil?
raise MicropubError.new("invalid_request", raise JsonError.new("invalid_request",
"Request to media endpoint requires a object named 'file'", "Request to media endpoint requires a object named 'file'",
400, request_json: params) 400, request_json: params)
end end
image = Image.new image = Image.new
image.blob.attach params[:file] image.blob.attach params[:file]
image.save image.save
location = request.base_url + "/images/raw/#{image.id}/#{image.blob.filename.to_s}"
location = "/images/raw/#{image.id}/#{image.blob.filename.to_s}"
response.set_header("Location", location) response.set_header("Location", location)
head 201 #created head 201 #created
end end
def micropub_error_handler(micropub_error)
render json: micropub_error.to_json, status: micropub_error.status
end
private private
def set_auth_token
token = nil
if params[:access_token]
token = IndieAuthToken.find_by(access_token: params[:access_token])
elsif request.headers["Authorization"]
t = request.headers["Authorization"]
if t.start_with? "Bearer "
t2 = t.split("Bearer ",2).last
token = IndieAuthToken.find_by(access_token: t2)
end
end
if token.nil?
raise MicropubError.new("unauthorized", "This action requires an authentication token", 401)
end
@token = token
end
def validate_scope(scope)
unless @token.scope.split(" ").include? scope
raise MicropubError.new("insufficient_scope","The provided authentication token does not include the scope: '#{scope}' which is required for this action", 403, scope: scope)
end
end
# may return 404 directly if post not found # may return 404 directly if post not found
def post_from_url(params_url) def post_from_url(params_url)
post = nil post = nil
@ -193,7 +151,7 @@ class MicropubController < ApplicationController
end end
end end
if post.nil? if post.nil?
raise MicrpubError.new("not found","No post found at url: #{params_url}",404) raise JsonError.new("not found","No post found at url: #{params_url}",404)
end end
return post return post
end end
@ -201,7 +159,7 @@ class MicropubController < ApplicationController
# supported params: name, content, published # supported params: name, content, published
def post_from_params(params) def post_from_params(params)
unless params[:h] and params[:h]=="entry" unless params[:h] and params[:h]=="entry"
raise MicropubError.new("invalid_request","Only h-entry types supported by this server",400) raise JsonError.new("invalid_request","Only h-entry types supported by this server",400)
end end
content = "" content = ""
if params[:content] if params[:content]
@ -212,7 +170,7 @@ class MicropubController < ApplicationController
elsif params[:name] elsif params[:name]
content = "# #{params[:name]}" content = "# #{params[:name]}"
else else
raise MicropubError.new("invalid_request","New h-entry must have content or a name (or both)",400) raise JsonError.new("invalid_request","New h-entry must have content or a name (or both)",400)
end end
datetime = DateTime.now datetime = DateTime.now
if params[:published] if params[:published]

View File

@ -0,0 +1,156 @@
class MicrosubController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :set_auth_token
def get
# rails overwrites params[:action] with the controller action name
# we can use this syntax to fetch the original `action`
raw_action = request.query_parameters["action"]
if raw_action
if raw_action=="timeline"
get_timeline(params)
elsif raw_action=="follow"
get_subscriptions(params)
elsif raw_action=="channels"
list_channels
else
raise JsonError.new("invalid_request", "This request has an unknown action of #{raw_action}", 400)
end
else
raise JsonError.new("invalid_request","Microsub requests must have an action parameter", 400)
end
end
def create
raw_action = request.request_parameters["action"]
if raw_action
if raw_action == "follow"
subscribe_to_feed(params)
elsif raw_action == "unfollow"
unsubscribe_from_feed(params)
else
raise JsonError.new("invalid_request", "This request has an unknown action of #{params[:action]}", 400)
end
else
raise JsonError.new("invalid_request","Microsub requests must have an action parameter", 400)
end
end
private
def get_timeline(params)
validate_scope("read")
@paging = nil
if params[:before] and params[:after]
@entries = @token.user.feed_entries
.where('sort_date < ?', params[:before])
.where('sort_date > ?', params[:after])
.order(sort_date: :desc).limit(20)
elsif params[:before]
@entries = @token.user.feed_entries
.where('sort_date < ?', params[:before])
.order(sort_date: :desc).limit(20)
elsif params[:after]
@entries = @token.user.feed_entries
.where('sort_date > ?', params[:after])
.order(sort_date: :desc).limit(20)
else
@entries = @token.user.feed_entries
.order(sort_date: :desc).limit(20)
end
unless @entries.count < 20
@paging = {
before: @entries.last.sort_date.iso8601,
after: @entries.first.sort_date.iso8601
}
end
items = @entries.map{|e|
{
type: "entry",
published: e.published.iso8601,
url: e.link,
uid: e.guid,
name: e.title,
content: {html: e.content},
author: {type: 'card', name: e.feed.name, url: e.feed.url},
_id: e.id
}
}
result = {}
result[:items] = items
result[:paging] = @paging unless @paging.nil?
render json: result, status: 200
end
def get_subscriptions(params)
validate_scope("read")
items = []
if params[:channel] and params[:channel] == "default"
@token.user.feeds.each do |feed|
items << {
type: "feed",
name: feed.name,
url: feed.url
}
end
end
render json: {items: items}, status: 200
end
def list_channels
validate_scope("read")
items = []
render json: {
channels: [
{uid: "notifications", name: "Notifications"},
{uid: "default", name: "Default"}
]
}
end
def subscribe_to_feed(params)
validate_scope("follow")
if params[:channel] and params[:channel] == "default"
feed_url = params[:url]
raise JsonError.new("invalid_request","A URL is required for this action", 400) if feed_url.nil?
## duplicate code as feed_controller#add_feed
feed_url_host = URI(feed_url).host
request_host = URI(request.base_url).host
matching_feed = Feed.find_by(url: feed_url, user: current_user)
if (feed_url_host == request_host)
raise JsonError.new("invalid_request","You cannot subscribe to yourself", 400)
elsif matching_feed.nil?
feed = @token.user.feeds.create!(url: feed_url)
UpdateFeedJob.perform_now(feed)
if feed.feed_invalid?
raise JsonError.new("invalid_request","Error adding #{feed_url} to your feeds", 400)
else
render json: {type: "feed", url: feed_url}, status: 201
end
else # feed already exists
render json: {type: "feed", url: feed_url}, status: 200
end
else
raise JsonError.new("invalid_request","This server only allows subscribing to the 'default' channel", 400)
end
end
def unsubscribe_from_feed(params)
validate_scope("follow")
if params[:channel] and params[:channel] == "default"
if params[:url]
feed = @token.user.feeds.find_by(url: params[:url])
if feed.nil?
raise JsonError.new("not_found", "No feed found with URL #{params[:url]}", 404)
else
feed.destroy!
head 204
end
else
raise JsonError.new("invalid_request", "A URL is required for this action", 400)
end
else
raise JsonError.new("invalid_request", "This server only allows managing subscriptions on the 'default' channel", 400)
end
end
end

View File

@ -13,6 +13,7 @@
<link rel="authorization_endpoint" href="<%=indie_authorization_endpoint_url %>" /> <link rel="authorization_endpoint" href="<%=indie_authorization_endpoint_url %>" />
<link rel="token_endpoint" href="<%= indie_token_endpoint_url %>" /> <link rel="token_endpoint" href="<%= indie_token_endpoint_url %>" />
<link rel="micropub" href="<%= micropub_url %>" /> <link rel="micropub" href="<%= micropub_url %>" />
<link rel="microsub" href="<%= microsub_url %>" />
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= csp_meta_tag %>

View File

@ -12,6 +12,9 @@ Rails.application.routes.draw do
post 'micropub', to: 'micropub#create' post 'micropub', to: 'micropub#create'
post 'micropub_media', to: 'micropub#media', as: 'micropub_media' post 'micropub_media', to: 'micropub#media', as: 'micropub_media'
get 'microsub', to: 'microsub#get', as: "microsub"
post 'microsub', to: 'microsub#create'
resources :feeds, only: [:index, :create, :destroy] resources :feeds, only: [:index, :create, :destroy]
get 'read', to: 'feeds#read' get 'read', to: 'feeds#read'
get 'read/:id', to: 'feeds#read_feed', as: 'read_feed' get 'read/:id', to: 'feeds#read_feed', as: 'read_feed'

View File

@ -5,8 +5,8 @@ class IndieAuthScopes
"profile" => "see your display name", "profile" => "see your display name",
"email" => "see your email address", "email" => "see your email address",
## Microsub ## Microsub
# "read" => "fetch your Haven Reader feed", "read" => "fetch your Haven Reader feed",
# "follow" => "subscribe to other sites in your Haven Reader", "follow" => "subscribe to other sites in your Haven Reader",
# "mute" => "silence a site in your Haven Reader", # "mute" => "silence a site in your Haven Reader",
# "block" => "silence a site in your Haven Reader", # "block" => "silence a site in your Haven Reader",
#"channels", #"channels",

View File

@ -0,0 +1,108 @@
require "test_helper"
class IndieAuthTest < ActionDispatch::IntegrationTest
test "can subscribe to feeds with microsub" do
token = create_washington_auth_token("profile email read follow")
# fetch (empty) feed list
get '/microsub', params: {
access_token: token,
action: "follow",
channel: "default"
}
assert_response :success
response_json = JSON.parse(response.body)
assert response_json.keys.include? "items"
assert_equal response_json["items"], []
# subscribe to a feed
feed_url = "https://havenweb.org/feed.xml"
post '/microsub', params: {
access_token: token,
action: "follow",
channel: "default",
url: feed_url
}
assert_response :created
response_json = JSON.parse(response.body)
assert_equal response_json["type"], "feed"
assert_equal response_json["url"], feed_url
# fetch (populated) feed list
get '/microsub', params: {
access_token: token,
action: "follow",
channel: "default"
}
assert_response :success
response_json = JSON.parse(response.body)
assert response_json.keys.include? "items"
assert_equal response_json["items"].size, 1
# fetch timeline
get '/microsub', params: {
access_token: token,
action: "timeline",
channel: "default"
}
assert_response :success
response_json = JSON.parse(response.body)
assert response_json["items"].size > 0
assert_equal response_json["items"].first["type"], "entry"
end
private
# context: scope, state, code_verifier, client_id, redirect_uri
def create_washington_auth_request(scope)
post user_session_path, params: {user: {
email: users(:washington).email,
password: "georgepass"
}}
context = {}
context["scope"] = scope
context["state"] = SecureRandom.urlsafe_base64(10)
context["code_verifier"] = SecureRandom.urlsafe_base64(10)
context["client_id"] = "http://localhost:12345"
context["redirect_uri"] = "http://localhost:12345/redirect"
approval_params = {}
scope.split(" ").each {|s| approval_params[s] = 1}
approval_params["code_challenge"] =
Base64.urlsafe_encode64(
Digest::SHA256.digest(
context["code_verifier"])).chomp("=")
approval_params["commit"] = "Approve"
["state", "client_id", "redirect_uri"].each do |p|
approval_params[p] = context[p]
end
post indie_auth_approval_path, params: approval_params
return context
end
def create_washington_auth_token(scope)
context = create_washington_auth_request(scope)
assert_response :redirect
redirected_url = URI.parse(response.headers['Location'])
query_parameters = CGI.parse(redirected_url.query)
# use the auth request to fetch a token
post indie_token_endpoint_path, params: {
"grant_type" => "authorization_code",
"code" => query_parameters["code"].first,
"client_id" => context["client_id"],
"redirect_uri" => context["redirect_uri"],
"code_verifier" => context["code_verifier"]
}
assert_response :success
response_json = JSON.parse(response.body)
assert response_json.keys.include?("access_token")
return response_json["access_token"]
end
end