rewrite webfinger client and specs; now this is much easier to maintain.
This commit is contained in:
parent
392e317962
commit
38ad76d9c7
2 changed files with 253 additions and 193 deletions
156
lib/webfinger.rb
156
lib/webfinger.rb
|
|
@ -2,126 +2,110 @@ require File.join(Rails.root, 'lib/hcard')
|
||||||
require File.join(Rails.root, 'lib/webfinger_profile')
|
require File.join(Rails.root, 'lib/webfinger_profile')
|
||||||
|
|
||||||
class Webfinger
|
class Webfinger
|
||||||
class WebfingerFailedError < RuntimeError; end
|
attr_accessor :host_meta_xrd, :webfinger_profile_xrd,
|
||||||
|
:webfinger_profile, :hcard, :hcard_xrd, :person,
|
||||||
|
:account, :ssl
|
||||||
|
|
||||||
def initialize(account)
|
def initialize(account)
|
||||||
@account = account.strip.gsub('acct:','').to_s.downcase
|
self.account = account
|
||||||
@ssl = true
|
self.ssl = true
|
||||||
Rails.logger.info("event=webfinger status=initialized target=#{account}")
|
Rails.logger.info("event=webfinger status=initialized target=#{account}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
def fetch
|
||||||
|
return person if existing_person_with_profile?
|
||||||
|
create_or_update_person_from_webfinger_profile!
|
||||||
|
end
|
||||||
|
|
||||||
def self.in_background(account, opts={})
|
def self.in_background(account, opts={})
|
||||||
Resque.enqueue(Jobs::FetchWebfinger, account)
|
Resque.enqueue(Jobs::FetchWebfinger, account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch
|
#everything below should be private I guess
|
||||||
|
def account=(str)
|
||||||
|
@account = str.strip.gsub('acct:','').to_s.downcase
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(url)
|
||||||
|
Rails.logger.info("Getting: #{url} for #{account}")
|
||||||
begin
|
begin
|
||||||
person = Person.by_account_identifier(@account)
|
Faraday.get(url).body
|
||||||
if person
|
|
||||||
if person.profile
|
|
||||||
Rails.logger.info("event=webfinger status=success route=local target=#{@account}")
|
|
||||||
return person
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
profile_url = get_xrd
|
|
||||||
webfinger_profile = get_webfinger_profile(profile_url)
|
|
||||||
if person
|
|
||||||
person.assign_new_profile_from_hcard(get_hcard(webfinger_profile))
|
|
||||||
fingered_person = person
|
|
||||||
else
|
|
||||||
fingered_person = make_person_from_webfinger(webfinger_profile)
|
|
||||||
end
|
|
||||||
|
|
||||||
if fingered_person
|
|
||||||
Rails.logger.info("event=webfinger status=success route=remote target=#{@account}")
|
|
||||||
fingered_person
|
|
||||||
else
|
|
||||||
Rails.logger.info("event=webfinger status=failure route=remote target=#{@account}")
|
|
||||||
raise WebfingerFailedError.new(@account)
|
|
||||||
end
|
|
||||||
rescue Exception => e
|
rescue Exception => e
|
||||||
Rails.logger.info("event=receive status=abort recipient=#{@account} reason='#{e.message}'")
|
Rails.logger.info("Failed to fetch: #{url} for #{account}; #{e.message}")
|
||||||
nil
|
raise e
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
def existing_person_with_profile?
|
||||||
def get_xrd
|
cached_person.present? && cached_person.profile.present?
|
||||||
begin
|
end
|
||||||
http = Faraday.get xrd_url
|
|
||||||
|
|
||||||
profile_url = webfinger_profile_url(http.body)
|
def cached_person
|
||||||
if profile_url
|
self.person ||= Person.by_account_identifier(account)
|
||||||
return profile_url
|
end
|
||||||
else
|
|
||||||
raise "no profile URL"
|
def create_or_update_person_from_webfinger_profile!
|
||||||
end
|
if person #update my profile please
|
||||||
|
person.assign_new_profile_from_hcard(self.hcard)
|
||||||
|
else
|
||||||
|
person = make_person_from_webfinger
|
||||||
|
end
|
||||||
|
Rails.logger.info("event=webfinger status=success route=remote target=#{@account}")
|
||||||
|
person
|
||||||
|
end
|
||||||
|
|
||||||
|
#this tries the xrl url with https first, then falls back to http
|
||||||
|
def host_meta_xrd
|
||||||
|
begin
|
||||||
|
get(host_meta_url)
|
||||||
rescue Exception => e
|
rescue Exception => e
|
||||||
if @ssl
|
if self.ssl
|
||||||
@ssl = false
|
self.ssl = false
|
||||||
retry
|
retry
|
||||||
else
|
else
|
||||||
raise e
|
raise I18n.t('webfinger.xrd_fetch_failed', :account => account)
|
||||||
raise I18n.t('webfinger.xrd_fetch_failed', :account => @account)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
def get_webfinger_profile(profile_url)
|
def hcard
|
||||||
begin
|
@hcard ||= HCard.build(hcard_xrd)
|
||||||
http = Faraday.get(profile_url)
|
end
|
||||||
|
|
||||||
rescue
|
def webfinger_profile
|
||||||
raise I18n.t('webfinger.fetch_failed', :profile_url => profile_url)
|
@webfinger_profile ||= WebfingerProfile.new(account, webfinger_profile_xrd)
|
||||||
end
|
|
||||||
return http.body
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def hcard_url
|
def hcard_url
|
||||||
@wf_profile.hcard
|
self.webfinger_profile.hcard
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_hcard(webfinger_profile)
|
def webfinger_profile_url
|
||||||
unless webfinger_profile.strip == ""
|
doc = Nokogiri::XML::Document.parse(self.host_meta_xrd)
|
||||||
|
|
||||||
@wf_profile = WebfingerProfile.new(@account, webfinger_profile)
|
|
||||||
|
|
||||||
begin
|
|
||||||
hcard = Faraday.get(hcard_url)
|
|
||||||
rescue
|
|
||||||
return I18n.t('webfinger.hcard_fetch_failed', :account => @account)
|
|
||||||
end
|
|
||||||
|
|
||||||
HCard.build hcard.body
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def make_person_from_webfinger(webfinger_profile)
|
|
||||||
card = get_hcard(webfinger_profile)
|
|
||||||
if card && @wf_profile
|
|
||||||
Person.create_from_webfinger(@wf_profile, card)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
##helpers
|
|
||||||
private
|
|
||||||
|
|
||||||
def webfinger_profile_url(xrd_response)
|
|
||||||
doc = Nokogiri::XML::Document.parse(xrd_response)
|
|
||||||
return nil if doc.namespaces["xmlns"] != "http://docs.oasis-open.org/ns/xri/xrd-1.0"
|
return nil if doc.namespaces["xmlns"] != "http://docs.oasis-open.org/ns/xri/xrd-1.0"
|
||||||
swizzle doc.at('Link[rel=lrdd]').attribute('template').value
|
swizzle doc.at('Link[rel=lrdd]').attribute('template').value
|
||||||
end
|
end
|
||||||
|
|
||||||
def xrd_url
|
def webfinger_profile_xrd
|
||||||
domain = @account.split('@')[1]
|
@webfinger_profile_xrd ||= get(webfinger_profile_url)
|
||||||
"http#{'s' if @ssl}://#{domain}/.well-known/host-meta"
|
end
|
||||||
|
|
||||||
|
def hcard_xrd
|
||||||
|
@hcard_xrd ||= get(hcard_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def make_person_from_webfinger
|
||||||
|
Person.create_from_webfinger(webfinger_profile, hcard)
|
||||||
|
end
|
||||||
|
|
||||||
|
def host_meta_url
|
||||||
|
domain = account.split('@')[1]
|
||||||
|
"http#{'s' if self.ssl}://#{domain}/.well-known/host-meta"
|
||||||
end
|
end
|
||||||
|
|
||||||
def swizzle(template)
|
def swizzle(template)
|
||||||
template.gsub '{uri}', @account
|
template.gsub('{uri}', account)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,125 +4,201 @@
|
||||||
|
|
||||||
require 'spec_helper'
|
require 'spec_helper'
|
||||||
|
|
||||||
require File.join(Rails.root, 'lib/webfinger')
|
|
||||||
|
|
||||||
describe Webfinger do
|
describe Webfinger do
|
||||||
let(:host_with_port) { AppConfig.bare_pod_uri }
|
let(:host_meta_xrd) { File.open(File.join(Rails.root, 'spec', 'fixtures', 'host-meta.fixture.html')).read }
|
||||||
let(:user1) { alice }
|
let(:webfinger_xrd) { File.open(File.join(Rails.root, 'spec', 'fixtures', 'webfinger.fixture.html')).read }
|
||||||
let(:user2) { eve }
|
|
||||||
|
|
||||||
let(:account) { "foo@tom.joindiaspora.com" }
|
|
||||||
let(:person) { Factory(:person, :diaspora_handle => account) }
|
|
||||||
let(:finger) { Webfinger.new(account) }
|
|
||||||
|
|
||||||
let(:good_request) { FakeHttpRequest.new(:success) }
|
|
||||||
|
|
||||||
let(:diaspora_xrd) { File.open(File.join(Rails.root, 'spec', 'fixtures', 'host-meta.fixture.html')).read }
|
|
||||||
let(:diaspora_finger) { File.open(File.join(Rails.root, 'spec', 'fixtures', 'webfinger.fixture.html')).read }
|
|
||||||
let(:hcard_xml) { File.open(File.join(Rails.root, 'spec', 'fixtures', 'hcard.fixture.html')).read }
|
let(:hcard_xml) { File.open(File.join(Rails.root, 'spec', 'fixtures', 'hcard.fixture.html')).read }
|
||||||
|
let(:account){'foo@bar.com'}
|
||||||
|
let(:account_in_fixtures){"alice@localhost:9887"}
|
||||||
|
let(:finger){Webfinger.new(account)}
|
||||||
|
let(:host_meta_url){"http://#{AppConfig[:pod_uri].authority}/webfinger?q="}
|
||||||
|
|
||||||
context 'setup' do
|
describe '#intialize' do
|
||||||
|
it 'sets account ' do
|
||||||
describe '#intialize' do
|
n = Webfinger.new("mbs348@gmail.com")
|
||||||
it 'sets account ' do
|
n.account.should_not be nil
|
||||||
n = Webfinger.new("mbs348@gmail.com")
|
|
||||||
n.instance_variable_get(:@account).should_not be nil
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'downcases account' do
|
|
||||||
account = "BIGBOY@Example.Org"
|
|
||||||
n = Webfinger.new(account)
|
|
||||||
n.instance_variable_get(:@account).should == account.downcase
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'should set ssl as the default' do
|
|
||||||
foo = Webfinger.new(account)
|
|
||||||
foo.instance_variable_get(:@ssl).should be true
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'webfinger query chain processing' do
|
it "downcases account and strips whitespace, and gsub 'acct:'" do
|
||||||
describe '#webfinger_profile_url' do
|
n = Webfinger.new("acct:BIGBOY@Example.Org ")
|
||||||
it 'parses out the webfinger template' do
|
n.account.should == 'bigboy@example.org'
|
||||||
finger.send(:webfinger_profile_url, diaspora_xrd).should ==
|
|
||||||
"http://#{AppConfig[:pod_uri].authority}/webfinger?q=foo@tom.joindiaspora.com"
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'should return nil if not an xrd' do
|
|
||||||
finger.send(:webfinger_profile_url, '<html></html>').should be nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#xrd_url' do
|
|
||||||
it 'should return canonical host-meta url for http' do
|
|
||||||
finger.instance_variable_set(:@ssl, false)
|
|
||||||
finger.send(:xrd_url).should == "http://tom.joindiaspora.com/.well-known/host-meta"
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'can return the https version' do
|
|
||||||
finger.send(:xrd_url).should == "https://tom.joindiaspora.com/.well-known/host-meta"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#get_xrd' do
|
it 'should set ssl as the default' do
|
||||||
it 'follows redirects' do
|
foo = Webfinger.new(account)
|
||||||
redirect_url = "http://whereami.whatisthis/host-meta"
|
foo.ssl.should be true
|
||||||
stub_request(:get, "https://tom.joindiaspora.com/.well-known/host-meta").
|
|
||||||
to_return(:status => 302, :headers => { 'Location' => redirect_url })
|
|
||||||
stub_request(:get, redirect_url).
|
|
||||||
to_return(:status => 200, :body => diaspora_xrd)
|
|
||||||
begin
|
|
||||||
finger.send :get_xrd
|
|
||||||
rescue; end
|
|
||||||
a_request(:get, redirect_url).should have_been_made
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.in_background' do
|
||||||
context 'webfingering local people' do
|
it 'enqueues a Jobs::FetchWebfinger job' do
|
||||||
it 'should return a person from the database if it matches its handle' do
|
Resque.should_receive(:enqueue).with(Jobs::FetchWebfinger, account)
|
||||||
person.save
|
Webfinger.in_background(account)
|
||||||
finger.fetch.id.should == person.id
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
it 'should fetch a diaspora webfinger and make a person for them' do
|
end
|
||||||
User.delete_all; Person.delete_all; Profile.delete_all
|
|
||||||
hcard_url = "http://google-1655890.com/hcard/users/29a9d5ae5169ab0b"
|
|
||||||
|
|
||||||
f = Webfinger.new("alice@#{host_with_port}")
|
describe '#fetch' do
|
||||||
stub_request(:get, f.send(:xrd_url)).
|
it 'works' do
|
||||||
to_return(:status => 200, :body => diaspora_xrd, :headers => {})
|
finger = Webfinger.new(account_in_fixtures)
|
||||||
stub_request(:get, f.send(:webfinger_profile_url, diaspora_xrd)).
|
finger.stub(:host_meta_xrd).and_return(host_meta_xrd)
|
||||||
to_return(:status => 200, :body => diaspora_finger, :headers => {})
|
finger.stub(:hcard_xrd).and_return(hcard_xml)
|
||||||
f.should_receive(:hcard_url).and_return(hcard_url)
|
finger.stub(:webfinger_profile_xrd).and_return(webfinger_xrd)
|
||||||
|
person = finger.fetch
|
||||||
stub_request(:get, hcard_url).
|
|
||||||
to_return(:status => 200, :body => hcard_xml, :headers => {})
|
|
||||||
|
|
||||||
person = f.fetch
|
|
||||||
|
|
||||||
WebMock.should have_requested(:get, f.send(:xrd_url))
|
|
||||||
WebMock.should have_requested(:get, f.send(:webfinger_profile_url, diaspora_xrd))
|
|
||||||
WebMock.should have_requested(:get, hcard_url)
|
|
||||||
person.should be_valid
|
person.should be_valid
|
||||||
end
|
person.should be_a Person
|
||||||
|
|
||||||
it 'should retry with http if https fails' do
|
|
||||||
f = Webfinger.new("tom@tom.joindiaspora.com")
|
|
||||||
xrd_url = "://tom.joindiaspora.com/.well-known/host-meta"
|
|
||||||
|
|
||||||
stub_request(:get, "https#{xrd_url}").
|
|
||||||
to_return(:status => 503, :body => "", :headers => {})
|
|
||||||
stub_request(:get, "http#{xrd_url}").
|
|
||||||
to_return(:status => 200, :body => diaspora_xrd, :headers => {})
|
|
||||||
|
|
||||||
#Faraday::Connection.any_instance.should_receive(:get).twice.and_return(nil, diaspora_xrd)
|
|
||||||
f.send(:get_xrd)
|
|
||||||
WebMock.should have_requested(:get,"https#{xrd_url}")
|
|
||||||
WebMock.should have_requested(:get,"http#{xrd_url}")
|
|
||||||
f.instance_variable_get(:@ssl).should == false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#get' do
|
||||||
|
it 'makes a request and grabs the body' do
|
||||||
|
url ="https://bar.com/.well-known/host-meta"
|
||||||
|
stub_request(:get, url).
|
||||||
|
to_return(:status => 200, :body => host_meta_xrd)
|
||||||
|
|
||||||
|
finger.get(url).should == host_meta_xrd
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'follows redirects' do
|
||||||
|
redirect_url = "http://whereami.whatisthis/host-meta"
|
||||||
|
|
||||||
|
stub_request(:get, "https://bar.com/.well-known/host-meta").
|
||||||
|
to_return(:status => 302, :headers => { 'Location' => redirect_url })
|
||||||
|
|
||||||
|
stub_request(:get, redirect_url).
|
||||||
|
to_return(:status => 200, :body => host_meta_xrd)
|
||||||
|
|
||||||
|
finger.host_meta_xrd
|
||||||
|
|
||||||
|
a_request(:get, redirect_url).should have_been_made
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'existing_person_with_profile?' do
|
||||||
|
it 'returns true if cached_person is present and has a profile' do
|
||||||
|
finger.should_receive(:cached_person).twice.and_return(Factory(:person))
|
||||||
|
finger.existing_person_with_profile?.should be_true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false if it has no person' do
|
||||||
|
finger.stub(:cached_person).and_return false
|
||||||
|
finger.existing_person_with_profile?.should be_false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false if the person has no profile' do
|
||||||
|
p = Factory(:person)
|
||||||
|
p.profile = nil
|
||||||
|
finger.stub(:cached_person).and_return(p)
|
||||||
|
finger.existing_person_with_profile?.should be_false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'cached_person' do
|
||||||
|
it 'sets the person by looking up the account from Person.by_account_identifier' do
|
||||||
|
person = stub
|
||||||
|
Person.should_receive(:by_account_identifier).with(account).and_return(person)
|
||||||
|
finger.cached_person.should == person
|
||||||
|
finger.person.should == person
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
describe 'create_or_update_person_from_webfinger_profile!' do
|
||||||
|
context 'with a cached_person' do
|
||||||
|
it 'calls Person#assign_new_profile_from_hcard with the fetched hcard' do
|
||||||
|
finger.hcard_xrd = hcard_xml
|
||||||
|
finger.stub(:person).and_return(bob.person)
|
||||||
|
bob.person.should_receive(:assign_new_profile_from_hcard).with(finger.hcard)
|
||||||
|
finger.create_or_update_person_from_webfinger_profile!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with no cached person' do
|
||||||
|
it 'sets person based on make_person_from_webfinger' do
|
||||||
|
finger.stub(:person).and_return(nil)
|
||||||
|
finger.should_receive(:make_person_from_webfinger)
|
||||||
|
finger.create_or_update_person_from_webfinger_profile!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#host_meta_xrd' do
|
||||||
|
it 'calls #get with host_meta_url' do
|
||||||
|
finger.stub(:host_meta_url).and_return('meta')
|
||||||
|
finger.should_receive(:get).with('meta')
|
||||||
|
finger.host_meta_xrd
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'should retry with ssl off a second time' do
|
||||||
|
finger.should_receive(:get).and_raise
|
||||||
|
finger.should_receive(:get)
|
||||||
|
finger.host_meta_xrd
|
||||||
|
finger.ssl.should be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#hcard' do
|
||||||
|
it 'calls HCard.build' do
|
||||||
|
finger.stub(:hcard_xrd).and_return(hcard_xml)
|
||||||
|
HCard.should_receive(:build).with(hcard_xml).and_return true
|
||||||
|
finger.hcard.should_not be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#webfinger_profile' do
|
||||||
|
it 'constructs a new WebfingerProfile object' do
|
||||||
|
finger.stub(:webfinger_profile_xrd).and_return(webfinger_xrd)
|
||||||
|
WebfingerProfile.should_receive(:new).with(account, webfinger_xrd)
|
||||||
|
finger.webfinger_profile
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#webfinger_profile_url' do
|
||||||
|
it 'returns the llrd link for a valid host meta' do
|
||||||
|
finger.stub(:host_meta_xrd).and_return(host_meta_xrd)
|
||||||
|
finger.webfinger_profile_url.should_not be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns nil if no link is found' do
|
||||||
|
finger.stub(:host_meta_xrd).and_return(nil)
|
||||||
|
finger.webfinger_profile_url.should be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#webfinger_profile_xrd' do
|
||||||
|
it 'calls #get with the hcard_url' do
|
||||||
|
finger.stub(:hcard_url).and_return("url")
|
||||||
|
finger.should_receive(:get).with("url")
|
||||||
|
finger.hcard_xrd
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#make_person_from_webfinger' do
|
||||||
|
it 'with an hcard and a webfinger_profile, it calls Person.create_from_webfinger' do
|
||||||
|
finger.stub(:hcard).and_return("hcard")
|
||||||
|
finger.stub(:webfinger_profile).and_return("webfinger_profile")
|
||||||
|
Person.should_receive(:create_from_webfinger).with("webfinger_profile", "hcard")
|
||||||
|
finger.make_person_from_webfinger
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
describe '#host_meta_url' do
|
||||||
|
it 'should return canonical host-meta url for http' do
|
||||||
|
finger.ssl = false
|
||||||
|
finger.host_meta_url.should == "http://bar.com/.well-known/host-meta"
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'can return the https version' do
|
||||||
|
finger.host_meta_url.should == "https://bar.com/.well-known/host-meta"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'swizzle' do
|
||||||
|
it 'gsubs out {uri} for the account' do
|
||||||
|
string = "{uri} is the coolest"
|
||||||
|
finger.swizzle(string).should == "#{finger.account} is the coolest"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue