diff --git a/forms/registration/snikket_custom.rb b/forms/registration/snikket_custom.rb new file mode 100644 index 0000000000000000000000000000000000000000..1e0da16c1a7183b795aa33af1b5886f80384fc21 --- /dev/null +++ b/forms/registration/snikket_custom.rb @@ -0,0 +1,22 @@ +form! +title "Setup Snikket Instance" + +instructions( + "Phone number #{@tel} has been activated. " \ + "Now you will need a Jabber ID to associate with your new JMP account. " \ + "JMP has partnered with Snikket CIC to provide you with your very own " \ + "Snikket instance that will provide Jabber service to you and anyone you " \ + "invite.\n\n" \ + "A Jabber ID is kind of like an email address, and looks similar too. " \ + "People who contact you using your Jabber ID instead of your phone number " \ + "will get additional features, such as video calling and end-to-end " \ + "encryption.\n\n" \ + "You will be prompted to choose a username after your instance is ready." +) + +field( + label: "Domain (or subdomain) name", + var: "domain", + description: @error || + "Your Jabber ID will be of the form username@domain" +) diff --git a/forms/registration/snikket_needs_dns.rb b/forms/registration/snikket_needs_dns.rb new file mode 100644 index 0000000000000000000000000000000000000000..40460676a8310650f17ef9c6a6036df950641f85 --- /dev/null +++ b/forms/registration/snikket_needs_dns.rb @@ -0,0 +1,16 @@ +form! +title "Setup Snikket Instance" + +instructions( + "To complete setup of your instance, please set these DNS records, " \ + "then press next to check if they have propagated." +) + +table( + @records, + name: "Name", + type: "Type", + status: "Status", + expected: "Expected", + found: "Found" +) diff --git a/lib/registration.rb b/lib/registration.rb index 0430606c830dac848a79ca4d79f10921872542fb..687149d4f4d3fb4b2d2c78d0a43276247b00ca23 100644 --- a/lib/registration.rb +++ b/lib/registration.rb @@ -612,28 +612,16 @@ class Registration end end - def initialize(customer, tel, error: nil, db:) + def initialize(customer, tel, error: nil, old: nil, db:) @customer = customer @tel = tel @error = error @db = db + @old = old end ACTION_VAR = "http://jabber.org/protocol/commands#actions" - def write - Command.reply { |reply| - reply.allowed_actions = [:next] - reply.command << form - }.then do |iq| - if iq.form.field(ACTION_VAR)&.value == "custom_domain" - CustomDomain.new(@tel).write - else - launch("#{iq.form.field('subdomain')&.value}.snikket.chat") - end - end - end - def form FormTemplate.render( "registration/snikket", @@ -642,6 +630,25 @@ class Registration ) end + def write + Command.reply { |reply| + reply.allowed_actions = [:next] + reply.command << form + }.then(&method(:next_step)) + end + + def next_step(iq) + subdomain = iq.form.field("subdomain")&.value + domain = "#{subdomain}.snikket.chat" + if iq.form.field(ACTION_VAR)&.value == "custom_domain" + CustomDomain.new(@tel, old: @old).write + elsif @old && (!subdomain || domain == @old.domain) + GetInvite.for(@old, @tel).then(&:write) + else + launch(domain) + end + end + def launch(domain) IQ_MANAGER.write(::Snikket::Launch.new( nil, CONFIG[:snikket_hosting_api], domain: domain @@ -650,40 +657,88 @@ class Registration }.catch { |e| next EMPromise.reject(e) unless e.respond_to?(:text) - Snikket.new(@customer, @tel, error: e.text, db: @db).write + Snikket.new(@customer, @tel, old: @old, error: e.text, db: @db).write } end def save_instance_and_wait(domain, launched) instance = ::Snikket::CustomerInstance.for(@customer, domain, launched) - ::Snikket::Repo.new(db: @db).put(instance).then do - GetInvite.for(instance).then(&:write) + repo = ::Snikket::Repo.new(db: @db) + repo.del(@old).then { repo.put(instance) }.then do + if launched.status == :needs_dns + NeedsDNS.new(@customer, instance, @tel, launched.records).write + else + GetInvite.for(@customer, instance, @tel, db: @db).then(&:write) + end + end + end + + class NeedsDNS < Snikket + def initialize(customer, instance, tel, records, db: DB) + @customer = customer + @instance = instance + @tel = tel + @records = records + @db = db + end + + def form + FormTemplate.render( + "registration/snikket_needs_dns", + records: @records + ) + end + + def write + Command.reply { |reply| + reply.allowed_actions = [:prev, :next] + reply.command << form + }.then do |iq| + if iq.prev? + CustomDomain.new(@tel, old: @instance).write + else + launch(@instance.domain) + end + end end end class GetInvite - def self.for(instance) + def self.for(customer, instance, tel, db: DB) instance.fetch_invite.then do |xmpp_uri| if xmpp_uri GoToInvite.new(xmpp_uri) else - new(instance) + new(customer, instance, tel, db: db) end end end - def initialize(instance) + def initialize(customer, instance, tel, db: DB) + @customer = customer @instance = instance + @tel = tel + @db = db + end + + def form + FormTemplate.render( + "registration/snikket_wait", + domain: @instance.domain + ) end def write Command.reply { |reply| - reply.allowed_actions = [:next] - reply.command << FormTemplate.render( - "registration/snikket_wait", - domain: @instance.domain - ) - }.then { GetInvite.for(@instance).then(&:write) } + reply.allowed_actions = [:prev, :next] + reply.command << form + }.then do |iq| + if iq.prev? + Snikket.new(@customer, @tel, old: @instance, db: @db).write + else + GetInvite.for(@instance).then(&:write) + end + end end end @@ -701,25 +756,33 @@ class Registration end end - class CustomDomain - def initialize(tel) + class CustomDomain < Snikket + def initialize(customer, tel, old: nil, error: nil, db: DB) + @customer = customer @tel = tel + @error = error + @old = old + @db = db end - CONTACT_SUPPORT = - "Please contact JMP support to set up " \ - "an instance on an existing domain." + def form + FormTemplate.render( + "registration/snikket_custom", + tel: @tel, + error: @error + ) + end def write Command.reply { |reply| - reply.allowed_actions = [:prev] - reply.status = :canceled - reply.note_type = :info - reply.note_text = CONTACT_SUPPORT + reply.allowed_actions = [:prev, :next] + reply.command << form }.then do |iq| - raise "Action not allowed" unless iq.prev? - - Snikket.new(@tel).write + if iq.prev? + Snikket.new(@customer, @tel, db: @db, old: @old).write + else + launch(iq.form.field("domain")&.value) + end end end end diff --git a/lib/snikket.rb b/lib/snikket.rb index 9ce3d992ff89554950136ccd2dadd3dfa54dd76c..2d6536b7004340cdb7b6c8d66cc40841a3612aa6 100644 --- a/lib/snikket.rb +++ b/lib/snikket.rb @@ -47,11 +47,61 @@ module Snikket &.content end + def status + query + .at_xpath("./ns:status", ns: self.class.registered_ns) + &.content&.to_sym + end + + def records + query + .xpath("./ns:records/ns:record", ns: self.class.registered_ns) + &.map(&Record.method(:new)) + end + def query at_xpath("./ns:launched", ns: self.class.registered_ns) end end + class Record + NS = "xmpp:snikket.org/hosting/v1" + + def initialize(element) + @element = element + end + + def name + @element + .at_xpath("./ns:name", ns: NS) + &.content + end + + def type + @element + .at_xpath("./ns:type", ns: NS) + &.content + end + + def status + @element + .at_xpath("./ns:status", ns: NS) + &.content&.to_sym + end + + def expected + @element + .xpath("./ns:expected/ns:value", ns: NS) + &.map(&:content) + end + + def found + @element + .xpath("./ns:found/ns:value", ns: NS) + &.map(&:content) + end + end + class Instance < Blather::Stanza::Iq register :snikket_instance, "instance", "xmpp:snikket.org/hosting/v1" @@ -102,11 +152,36 @@ module Snikket end end + class Delete < Blather::Stanza::Iq + register nil, "delete", "xmpp:snikket.org/hosting/v1" + + def self.new(type=nil, to=nil, id=nil, instance_id: nil) + stanza = super(type || :set, to, id) + node = Nokogiri::XML::Node.new("delete", stanza.document) + node.default_namespace = registered_ns + stanza << node + stanza.instance_id = instance_id if instance_id + stanza + end + + def instance_id=(instance_id) + query.at_xpath("./ns:instance-id", ns: self.class.registered_ns)&.remove + node = Nokogiri::XML::Node.new("intance-id", document) + node.default_namespace = self.class.registered_ns + node.content = instance_id + query << node + end + + def query + at_xpath("./ns:delete", ns: self.class.registered_ns) + end + end + class CustomerInstance def self.for(customer, domain, launched) new( instance_id: launched.instance_id, - bootstrap_token: launched.bootstrap_token, + bootstrap_token: launched.bootstrap_token || "", customer_id: customer.customer_id, domain: domain ) @@ -153,6 +228,21 @@ module Snikket end end + def del(instance) + return EMPromise.resolve(nil) unless instance + + params = [instance.instance_id, instance.customer_id, instance.domain] + + IQ_MANAGER.write(Delete.new( + nil, CONFIG[:snikket_hosting_api], instance_id: instance.instance_id + )).then do + @db.exec_defer(<<~SQL, params) + DELETE FROM snikket_instances + WHERE instance_id=$1 AND customer_id=$2 AND domain=$3 + SQL + end + end + def put(instance) params = [ instance.instance_id, instance.bootstrap_token, @@ -163,6 +253,8 @@ module Snikket (instance_id, bootstrap_token, customer_id, domain) VALUES ($1, $2, $3, $4) + ON CONFLICT (instance_id) + DO UPDATE SET bootstrap_token=$2 SQL end end diff --git a/test/test_registration.rb b/test/test_registration.rb index c694d6911357ebc1f7ef3c6a6f3b55150161a8bc..1fe64ca87dd070cdd0c26d1a703eb1f19f0e743b 100644 --- a/test/test_registration.rb +++ b/test/test_registration.rb @@ -1443,4 +1443,124 @@ class RegistrationTest < Minitest::Test end em :test_write_already_taken end + + class SnikketCustomDomainTest < Minitest::Test + def setup + @snikket = Registration::FinishOnboarding::CustomDomain.new( + customer, + "+15555550000", + db: FakeDB.new + ) + end + + def test_write_needs_dns + domain_form = Blather::Stanza::Iq::Command.new + domain_form.form.fields = [ + { var: "domain", value: "snikket.example.com" } + ] + + launched = Snikket::Launched.new + launched << Niceogiri::XML::Node.new( + :launched, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |inner| + inner << Niceogiri::XML::Node.new( + :'instance-id', launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |id| + id.content = "si-1234" + } + inner << Niceogiri::XML::Node.new( + :status, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |id| + id.content = "needs_dns" + } + inner << Niceogiri::XML::Node.new( + :records, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |records| + records << Niceogiri::XML::Node.new( + :record, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |record| + record << Niceogiri::XML::Node.new( + :name, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |name| name.content = "snikket.example.com" } + record << Niceogiri::XML::Node.new( + :type, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |type| type.content = "AAAA" } + record << Niceogiri::XML::Node.new( + :status, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |type| type.content = "incorrect" } + record << Niceogiri::XML::Node.new( + :expected, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |expected| + expected << Niceogiri::XML::Node.new( + :value, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |value| value.content = "1::2" } + } + record << Niceogiri::XML::Node.new( + :found, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |found| + found << Niceogiri::XML::Node.new( + :value, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |value| value.content = "0::0" } + } + } + } + } + + result = execute_command do + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.resolve(domain_form), + [Matching.new do |iq| + assert_equal :form, iq.form.type + assert iq.form.field("domain") + end] + ) + + Registration::FinishOnboarding::Snikket::IQ_MANAGER.expect( + :write, + EMPromise.resolve(launched), + [Matching.new do |iq| + assert_equal :set, iq.type + assert_equal CONFIG[:snikket_hosting_api], iq.to.to_s + assert_equal( + "snikket.example.com", + iq.xpath( + "./ns:launch/ns:domain", + ns: "xmpp:snikket.org/hosting/v1" + ).text + ) + end] + ) + + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.reject(:test_result), + [Matching.new do |iq| + assert_equal :form, iq.form.type + assert_equal( + ["snikket.example.com"], + iq.form.xpath( + "./ns:item/ns:field[@var='name']/ns:value", + ns: "jabber:x:data" + ).map(&:content) + ) + assert_equal( + ["1::2"], + iq.form.xpath( + "./ns:item/ns:field[@var='expected']/ns:value", + ns: "jabber:x:data" + ).map(&:content) + ) + end] + ) + + @snikket.write.catch { |e| e } + end + + assert_equal :test_result, result + assert_mock Command::COMMAND_MANAGER + assert_mock Registration::FinishOnboarding::Snikket::IQ_MANAGER + end + em :test_write_needs_dns + end end