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,7 +1,50 @@
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?
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
return Setting.first || Setting.new
end

View File

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

View File

@ -1,26 +1,11 @@
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
before_action :set_auth_token
def query
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
if params[:q] == "config"
render json: {
@ -47,16 +32,17 @@ class MicropubController < ApplicationController
}, status: 200
end
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
# 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
def 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")
if params[:url]
post = post_from_url(params[:url])
@ -73,60 +59,60 @@ class MicropubController < ApplicationController
end
end
if params[:add]
raise MicropubError.new(
raise JsonError.new(
"invalid_request",
"None of the poperties this server supports (content, published) allow multiple values",
400)
end
if params[:delete]
raise MicropubError.new(
raise JsonError.new(
"invalid_request",
"All properties are required (content, published), none can be deleted",
400)
end
rescue => e
raise MicropubError.new(
raise JsonError.new(
"invalid_request",
"#{e.class.name}: #{e.message}",
400)
end ## end begin/rescue
else # no url param
raise MiropubError.new(
raise JsonError.new(
"invalid_request",
"Request includes an action of 'update' but is missing the URL of the post to update",
400)
return
end
elsif (params[:action]) and (params[:action] == "delete")
elsif (raw_action) and (raw_action == "delete")
validate_scope("delete")
if params[:url]
post = post_from_url(params[:url])
begin
post.destroy!
rescue => e
raise MicropubError.new(
raise JsonError.new(
"server_error",
"#{e.class.name}: #{e.message}",
500)
end
head 204 # no content from successful deletion
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",
400)
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.author = @token.user
@post.save!
response.set_header("Location", url_for(@post))
head 201 #created
elsif (params[:action]) # unknown action
raise MicropubError.new("invalid_request",
"Request includes an unrecognized action of '#{params[:action]}', only 'update' and 'delete' are recognized",
elsif (raw_action) # unknown action
raise JsonError.new("invalid_request",
"Request includes an unrecognized action of '#{raw_action}', only 'update' and 'delete' are recognized",
400, request_json: params)
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",
400, request_json: params)
end
@ -135,48 +121,20 @@ class MicropubController < ApplicationController
def media
validate_scope("media")
if params[:file].nil?
raise MicropubError.new("invalid_request",
raise JsonError.new("invalid_request",
"Request to media endpoint requires a object named 'file'",
400, request_json: params)
end
image = Image.new
image.blob.attach params[:file]
image.save
location = "/images/raw/#{image.id}/#{image.blob.filename.to_s}"
location = request.base_url + "/images/raw/#{image.id}/#{image.blob.filename.to_s}"
response.set_header("Location", location)
head 201 #created
end
def micropub_error_handler(micropub_error)
render json: micropub_error.to_json, status: micropub_error.status
end
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
def post_from_url(params_url)
post = nil
@ -193,7 +151,7 @@ class MicropubController < ApplicationController
end
end
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
return post
end
@ -201,7 +159,7 @@ class MicropubController < ApplicationController
# supported params: name, content, published
def post_from_params(params)
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
content = ""
if params[:content]
@ -212,7 +170,7 @@ class MicropubController < ApplicationController
elsif params[:name]
content = "# #{params[:name]}"
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
datetime = DateTime.now
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="token_endpoint" href="<%= indie_token_endpoint_url %>" />
<link rel="micropub" href="<%= micropub_url %>" />
<link rel="microsub" href="<%= microsub_url %>" />
<%= csrf_meta_tags %>
<%= csp_meta_tag %>

View File

@ -12,6 +12,9 @@ Rails.application.routes.draw do
post 'micropub', to: 'micropub#create'
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]
get 'read', to: 'feeds#read'
get 'read/:id', to: 'feeds#read_feed', as: 'read_feed'

View File

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