mirror of
https://github.com/havenweb/haven.git
synced 2025-07-16 16:44:22 +02:00
Microsub
This commit is contained in:
parent
c2054e21a8
commit
b3979cb8fe
@ -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
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
156
app/controllers/microsub_controller.rb
Normal file
156
app/controllers/microsub_controller.rb
Normal 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
|
@ -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 %>
|
||||
|
@ -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'
|
||||
|
@ -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",
|
||||
|
108
test/integration/microsub_test.rb
Normal file
108
test/integration/microsub_test.rb
Normal 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
|
Loading…
x
Reference in New Issue
Block a user