Add separate user accounts

pull/19/head
Omar Roth 7 years ago
parent 5b41c0f81b
commit d29ea79a5d

@ -10,6 +10,7 @@ CREATE TABLE public.users
subscriptions text[] COLLATE pg_catalog."default", subscriptions text[] COLLATE pg_catalog."default",
email text COLLATE pg_catalog."default" NOT NULL, email text COLLATE pg_catalog."default" NOT NULL,
preferences text COLLATE pg_catalog."default", preferences text COLLATE pg_catalog."default",
password text COLLATE pg_catalog."default",
CONSTRAINT users_email_key UNIQUE (email), CONSTRAINT users_email_key UNIQUE (email),
CONSTRAINT users_id_key UNIQUE (id) CONSTRAINT users_id_key UNIQUE (id)
) )

@ -14,15 +14,18 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
require "crypto/bcrypt/password"
require "detect_language" require "detect_language"
require "kemal" require "kemal"
require "openssl/hmac"
require "option_parser" require "option_parser"
require "pg" require "pg"
require "xml" require "xml"
require "yaml" require "yaml"
require "./invidious/*" require "./invidious/*"
CONFIG = Config.from_yaml(File.read("config/config.yml")) CONFIG = Config.from_yaml(File.read("config/config.yml"))
HMAC_KEY = Random::Secure.random_bytes(32)
crawl_threads = CONFIG.crawl_threads crawl_threads = CONFIG.crawl_threads
channel_threads = CONFIG.channel_threads channel_threads = CONFIG.channel_threads
@ -233,12 +236,21 @@ before_all do |env|
sid = env.request.cookies["SID"].value sid = env.request.cookies["SID"].value
begin # Invidious users only have SID
client = make_client(YT_URL) if !env.request.cookies.has_key? "SSID"
user = get_user(sid, client, headers, PG_DB, false) user = PG_DB.query_one?("SELECT * FROM users WHERE id = $1", sid, as: User)
env.set "user", user if user
rescue ex env.set "user", user
end
else
begin
client = make_client(YT_URL)
user = get_user(sid, client, headers, PG_DB, false)
env.set "user", user
rescue ex
end
end end
end end
end end
@ -514,9 +526,21 @@ get "/search" do |env|
end end
get "/login" do |env| get "/login" do |env|
user = env.get? "user"
if user
next env.redirect "/feed/subscriptions"
end
referer = env.request.headers["referer"]? referer = env.request.headers["referer"]?
referer ||= "/feed/subscriptions" referer ||= "/feed/subscriptions"
account_type = env.params.query["type"]?
account_type ||= "google"
if account_type == "invidious"
captcha = generate_captcha(HMAC_KEY)
end
tfa = env.params.query["tfa"]? tfa = env.params.query["tfa"]?
tfa ||= false tfa ||= false
@ -538,147 +562,236 @@ post "/login" do |env|
email = env.params.body["email"]? email = env.params.body["email"]?
password = env.params.body["password"]? password = env.params.body["password"]?
tfa_code = env.params.body["tfa"]?.try &.lchop("G-")
begin account_type = env.params.query["type"]?
client = make_client(LOGIN_URL) account_type ||= "google"
headers = HTTP::Headers.new
headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8" if account_type == "google"
headers["Google-Accounts-XSRF"] = "1" tfa_code = env.params.body["tfa"]?.try &.lchop("G-")
begin
client = make_client(LOGIN_URL)
headers = HTTP::Headers.new
headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8"
headers["Google-Accounts-XSRF"] = "1"
login_page = client.get("/ServiceLogin") login_page = client.get("/ServiceLogin")
headers = login_page.cookies.add_request_headers(headers) headers = login_page.cookies.add_request_headers(headers)
login_page = XML.parse_html(login_page.body) login_page = XML.parse_html(login_page.body)
inputs = {} of String => String inputs = {} of String => String
login_page.xpath_nodes(%q(//input[@type="submit"])).each do |node| login_page.xpath_nodes(%q(//input[@type="submit"])).each do |node|
name = node["id"]? || node["name"]? name = node["id"]? || node["name"]?
name ||= "" name ||= ""
value = node["value"]? value = node["value"]?
value ||= "" value ||= ""
if name != "" && value != "" if name != "" && value != ""
inputs[name] = value inputs[name] = value
end
end end
end
login_page.xpath_nodes(%q(//input[@type="hidden"])).each do |node| login_page.xpath_nodes(%q(//input[@type="hidden"])).each do |node|
name = node["id"]? || node["name"]? name = node["id"]? || node["name"]?
name ||= "" name ||= ""
value = node["value"]? value = node["value"]?
value ||= "" value ||= ""
if name != "" && value != "" if name != "" && value != ""
inputs[name] = value inputs[name] = value
end
end end
end
lookup_req = %(["#{email}",null,[],null,"US",null,null,2,false,true,[null,null,[2,1,null,1,"https://accounts.google.com/ServiceLogin?passive=1209600&continue=https%3A%2F%2Faccounts.google.com%2FManageAccount&followup=https%3A%2F%2Faccounts.google.com%2FManageAccount",null,[],4,[]],1,[null,null,[]],null,null,null,true],"#{email}"]) lookup_req = %(["#{email}",null,[],null,"US",null,null,2,false,true,[null,null,[2,1,null,1,"https://accounts.google.com/ServiceLogin?passive=1209600&continue=https%3A%2F%2Faccounts.google.com%2FManageAccount&followup=https%3A%2F%2Faccounts.google.com%2FManageAccount",null,[],4,[]],1,[null,null,[]],null,null,null,true],"#{email}"])
lookup_results = client.post("/_/signin/sl/lookup", headers, login_req(inputs, lookup_req)) lookup_results = client.post("/_/signin/sl/lookup", headers, login_req(inputs, lookup_req))
headers = lookup_results.cookies.add_request_headers(headers) headers = lookup_results.cookies.add_request_headers(headers)
lookup_results = lookup_results.body lookup_results = lookup_results.body
lookup_results = lookup_results[5..-1] lookup_results = lookup_results[5..-1]
lookup_results = JSON.parse(lookup_results) lookup_results = JSON.parse(lookup_results)
user_hash = lookup_results[0][2] user_hash = lookup_results[0][2]
challenge_req = %(["#{user_hash}",null,1,null,[1,null,null,null,["#{password}",null,true]],[null,null,[2,1,null,1,"https://accounts.google.com/ServiceLogin?passive=1209600&continue=https%3A%2F%2Faccounts.google.com%2FManageAccount&followup=https%3A%2F%2Faccounts.google.com%2FManageAccount",null,[],4,[]],1,[null,null,[]],null,null,null,true]]) challenge_req = %(["#{user_hash}",null,1,null,[1,null,null,null,["#{password}",null,true]],[null,null,[2,1,null,1,"https://accounts.google.com/ServiceLogin?passive=1209600&continue=https%3A%2F%2Faccounts.google.com%2FManageAccount&followup=https%3A%2F%2Faccounts.google.com%2FManageAccount",null,[],4,[]],1,[null,null,[]],null,null,null,true]])
challenge_results = client.post("/_/signin/sl/challenge", headers, login_req(inputs, challenge_req)) challenge_results = client.post("/_/signin/sl/challenge", headers, login_req(inputs, challenge_req))
headers = challenge_results.cookies.add_request_headers(headers) headers = challenge_results.cookies.add_request_headers(headers)
challenge_results = challenge_results.body challenge_results = challenge_results.body
challenge_results = challenge_results[5..-1] challenge_results = challenge_results[5..-1]
challenge_results = JSON.parse(challenge_results) challenge_results = JSON.parse(challenge_results)
headers["Cookie"] = URI.unescape(headers["Cookie"]) headers["Cookie"] = URI.unescape(headers["Cookie"])
if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED" if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED"
error_message = "Incorrect password" error_message = "Incorrect password"
next templated "error" next templated "error"
end end
if challenge_results[0][-1][0].as_a? if challenge_results[0][-1][0].as_a?
tfa = challenge_results[0][-1][0][0] tfa = challenge_results[0][-1][0][0]
if tfa[2] == "TWO_STEP_VERIFICATION" if tfa[2] == "TWO_STEP_VERIFICATION"
if tfa[5] == "QUOTA_EXCEEDED" if tfa[5] == "QUOTA_EXCEEDED"
error_message = "Quota exceeded, try again in a few hours" error_message = "Quota exceeded, try again in a few hours"
next templated "error" next templated "error"
end end
if !tfa_code if !tfa_code
next env.redirect "/login?tfa=true" next env.redirect "/login?tfa=true"
end end
tl = challenge_results[1][2] tl = challenge_results[1][2]
request_type = tfa[8]
case request_type
when 6
# Authenticator app
tfa_req = %(["#{user_hash}",null,2,null,[6,null,null,null,null,["#{tfa_code}",false]]])
when 9
# Voice or text message
tfa_req = %(["#{user_hash}",null,2,null,[9,null,null,null,null,null,null,null,[null,"#{tfa_code}",false,2]]])
else
error_message = "Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled."
next templated "error"
end
request_type = tfa[8] challenge_results = client.post("/_/signin/challenge?hl=en&TL=#{tl}", headers, login_req(inputs, tfa_req))
case request_type headers = challenge_results.cookies.add_request_headers(headers)
when 6
# Authenticator app challenge_results = challenge_results.body
tfa_req = %(["#{user_hash}",null,2,null,[6,null,null,null,null,["#{tfa_code}",false]]]) challenge_results = challenge_results[5..-1]
when 9 challenge_results = JSON.parse(challenge_results)
# Voice or text message
tfa_req = %(["#{user_hash}",null,2,null,[9,null,null,null,null,null,null,null,[null,"#{tfa_code}",false,2]]]) if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED"
else error_message = "Invalid TFA code"
error_message = "Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled." next templated "error"
next templated "error" end
end end
end
login_res = challenge_results[0][13][2].to_s
login = client.get(login_res, headers)
headers = login.cookies.add_request_headers(headers)
challenge_results = client.post("/_/signin/challenge?hl=en&TL=#{tl}", headers, login_req(inputs, tfa_req)) login = client.get(login.headers["Location"], headers)
headers = challenge_results.cookies.add_request_headers(headers)
challenge_results = challenge_results.body headers = HTTP::Headers.new
challenge_results = challenge_results[5..-1] headers = login.cookies.add_request_headers(headers)
challenge_results = JSON.parse(challenge_results)
sid = login.cookies["SID"].value
client = make_client(YT_URL)
user = get_user(sid, client, headers, PG_DB)
if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED" # We are now logged in
error_message = "Invalid TFA code"
next templated "error" host = URI.parse(env.request.headers["Host"]).host
login.cookies.each do |cookie|
if Kemal.config.ssl
cookie.secure = true
else
cookie.secure = false
end end
cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host)
cookie.extension = cookie.extension.not_nil!.gsub("Secure; ", "")
end end
login.cookies.add_response_headers(env.response.headers)
env.redirect referer
rescue ex
error_message = "Login failed. This may be because two-factor authentication is not enabled on your account."
next templated "error"
end end
elsif account_type == "invidious"
challenge_response = env.params.body["challenge_response"]?
token = env.params.body["token"]?
login_res = challenge_results[0][13][2].to_s action = env.params.body["action"]?
action ||= "signin"
login = client.get(login_res, headers) if !email
headers = login.cookies.add_request_headers(headers) error_message = "User ID is a required field"
next templated "error"
end
login = client.get(login.headers["Location"], headers) if !password
error_message = "Password is a required field"
next templated "error"
end
headers = HTTP::Headers.new if !challenge_response || !token
headers = login.cookies.add_request_headers(headers) error_message = "CAPTCHA is a required field"
next templated "error"
end
sid = login.cookies["SID"].value challenge_response = challenge_response.lstrip('0')
if OpenSSL::HMAC.digest(:sha256, HMAC_KEY, challenge_response) == Base64.decode(token)
else
error_message = "Invalid CAPTCHA response"
next templated "error"
end
client = make_client(YT_URL) if action == "signin"
user = get_user(sid, client, headers, PG_DB) user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1 AND password IS NOT NULL", email, as: User)
# We are now logged in if !user
error_message = "Cannot find user with ID #{email}."
next templated "error"
end
host = URI.parse(env.request.headers["Host"]).host if !user.password
error_message = "Account appears to be a Google account."
next templated "error"
end
if Crypto::Bcrypt::Password.new(user.password.not_nil!) == password
sid = Base64.encode(Random::Secure.random_bytes(50))
PG_DB.exec("UPDATE users SET id = $1 WHERE email = $2", sid, email)
if Kemal.config.ssl
secure = true
else
secure = false
end
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.now + 2.years, secure: secure, http_only: true)
else
error_message = "Invalid password"
next templated "error"
end
elsif action == "register"
user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1 AND password IS NOT NULL", email, as: User)
if user
error_message = "User already exists, please sign in"
next templated "error"
end
sid = Base64.encode(Random::Secure.random_bytes(50))
user = create_user(sid, email, password)
user_array = user.to_a
user_array[5] = user_array[5].to_json
args = arg_array(user_array)
PG_DB.exec("INSERT INTO users VALUES (#{args})", user_array)
login.cookies.each do |cookie|
if Kemal.config.ssl if Kemal.config.ssl
cookie.secure = true secure = true
else else
cookie.secure = false secure = false
end end
cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host) env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.now + 2.years, secure: secure, http_only: true)
cookie.extension = cookie.extension.not_nil!.gsub("Secure; ", "")
end end
login.cookies.add_response_headers(env.response.headers)
env.redirect referer env.redirect referer
rescue ex
error_message = "Login failed. This may be because two-factor authentication is not enabled on your account."
next templated "error"
end end
end end
@ -782,8 +895,10 @@ get "/feed/subscriptions" do |env|
headers = HTTP::Headers.new headers = HTTP::Headers.new
headers["Cookie"] = env.request.headers["Cookie"] headers["Cookie"] = env.request.headers["Cookie"]
client = make_client(YT_URL) if !user.password
user = get_user(user.id, client, headers, PG_DB) client = make_client(YT_URL)
user = get_user(user.id, client, headers, PG_DB)
end
max_results = preferences.max_results max_results = preferences.max_results
max_results ||= env.params.query["maxResults"]?.try &.to_i max_results ||= env.params.query["maxResults"]?.try &.to_i
@ -903,15 +1018,15 @@ get "/subscription_manager" do |env|
user = user.as(User) user = user.as(User)
# Refresh account if !user.password
headers = HTTP::Headers.new # Refresh account
headers["Cookie"] = env.request.headers["Cookie"] headers = HTTP::Headers.new
headers["Cookie"] = env.request.headers["Cookie"]
client = make_client(YT_URL)
user = get_user(user.id, client, headers, PG_DB)
client = make_client(YT_URL)
user = get_user(user.id, client, headers, PG_DB)
end
subscriptions = user.subscriptions subscriptions = user.subscriptions
subscriptions ||= [] of String
client = make_client(YT_URL) client = make_client(YT_URL)
subscriptions = subscriptions.map do |ucid| subscriptions = subscriptions.map do |ucid|
@ -941,34 +1056,50 @@ get "/subscription_ajax" do |env|
channel_id = env.params.query["c"]? channel_id = env.params.query["c"]?
channel_id ||= "" channel_id ||= ""
headers = HTTP::Headers.new if !user.password
headers["Cookie"] = env.request.headers["Cookie"] headers = HTTP::Headers.new
headers["Cookie"] = env.request.headers["Cookie"]
client = make_client(YT_URL) client = make_client(YT_URL)
subs = client.get("/subscription_manager?disable_polymer=1", headers) subs = client.get("/subscription_manager?disable_polymer=1", headers)
headers["Cookie"] += "; " + subs.cookies.add_request_headers(headers)["Cookie"] headers["Cookie"] += "; " + subs.cookies.add_request_headers(headers)["Cookie"]
match = subs.body.match(/'XSRF_TOKEN': "(?<session_token>[A-Za-z0-9\_\-\=]+)"/) match = subs.body.match(/'XSRF_TOKEN': "(?<session_token>[A-Za-z0-9\_\-\=]+)"/)
if match if match
session_token = match["session_token"] session_token = match["session_token"]
else else
next env.redirect "/" next env.redirect "/"
end end
headers["content-type"] = "application/x-www-form-urlencoded" headers["content-type"] = "application/x-www-form-urlencoded"
post_req = { post_req = {
"session_token" => session_token, "session_token" => session_token,
} }
post_req = HTTP::Params.encode(post_req) post_req = HTTP::Params.encode(post_req)
post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}" post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}"
# Update user # Update user
if client.post(post_url, headers, post_req).status_code == 200 if client.post(post_url, headers, post_req).status_code == 200
sid = user.id
case action
when .starts_with? "action_create"
PG_DB.exec("UPDATE users SET subscriptions = array_append(subscriptions,$1) WHERE id = $2", channel_id, sid)
when .starts_with? "action_remove"
PG_DB.exec("UPDATE users SET subscriptions = array_remove(subscriptions,$1) WHERE id = $2", channel_id, sid)
end
end
else
sid = user.id sid = user.id
case action case action
when .starts_with? "action_create" when .starts_with? "action_create"
PG_DB.exec("UPDATE users SET subscriptions = array_append(subscriptions,$1) WHERE id = $2", channel_id, sid) if !user.subscriptions.includes? channel_id
PG_DB.exec("UPDATE users SET subscriptions = array_append(subscriptions,$1) WHERE id = $2", channel_id, sid)
client = make_client(YT_URL)
get_channel(channel_id, client, PG_DB, false, false)
end
when .starts_with? "action_remove" when .starts_with? "action_remove"
PG_DB.exec("UPDATE users SET subscriptions = array_remove(subscriptions,$1) WHERE id = $2", channel_id, sid) PG_DB.exec("UPDATE users SET subscriptions = array_remove(subscriptions,$1) WHERE id = $2", channel_id, sid)
end end

@ -138,6 +138,7 @@ class User
default: DEFAULT_USER_PREFERENCES, default: DEFAULT_USER_PREFERENCES,
converter: PreferencesConverter, converter: PreferencesConverter,
}, },
password: String?,
}) })
end end
@ -814,7 +815,14 @@ def fetch_user(sid, client, headers, db)
email = "" email = ""
end end
user = User.new(sid, Time.now, [] of String, channels, email, DEFAULT_USER_PREFERENCES) user = User.new(sid, Time.now, [] of String, channels, email, DEFAULT_USER_PREFERENCES, nil)
return user
end
def create_user(sid, email, password)
password = Crypto::Bcrypt::Password.create(password, cost: 10)
user = User.new(sid, Time.now, [] of String, [] of String, email, DEFAULT_USER_PREFERENCES, password.to_s)
return user return user
end end
@ -947,3 +955,54 @@ def write_var_int(value : Int)
return bytes return bytes
end end
def generate_captcha(key)
minute = Random::Secure.rand(12)
minute_angle = minute * 30
minute = minute * 5
hour = Random::Secure.rand(12)
hour_angle = hour * 30 + minute_angle.to_f / 12
if hour == 0
hour = 12
end
clock_svg = <<-END_SVG
<svg viewBox="0 0 100 100" width="200px">
<circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle>
<circle id="hour1" cx="69" cy="17.091" r="2" fill="black"></circle>
<circle id="hour2" cx="82.909" cy="31" r="2" fill="black"></circle>
<circle id="hour3" cx="88" cy="50" r="2" fill="black"></circle>
<circle id="hour4" cx="82.909" cy="69" r="2" fill="black"></circle>
<circle id="hour5" cx="69" cy="82.909" r="2" fill="black"></circle>
<circle id="hour6" cx="50" cy="88" r="2" fill="black"></circle>
<circle id="hour7" cx="31" cy="82.909" r="2" fill="black"></circle>
<circle id="hour8" cx="17.091" cy="69" r="2" fill="black"></circle>
<circle id="hour9" cx="12" cy="50" r="2" fill="black"></circle>
<circle id="hour10" cx="17.091" cy="31" r="2" fill="black"></circle>
<circle id="hour11" cx="31" cy="17.091" r="2" fill="black"></circle>
<circle id="hour12" cx="50" cy="12" r="2" fill="black"></circle>
<circle cx="50" cy="50" r="3" fill="black"></circle>
<line id="minute" transform="rotate(#{minute_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="16" fill="black" stroke="black" stroke-width="2"></line>
<line id="hour" transform="rotate(#{hour_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="24" fill="black" stroke="black" stroke-width="2"></line>
</svg>
END_SVG
challenge = ""
convert = Process.run(%(convert -density 1200 -resize 400x400 -background none svg:- png:-), shell: true, input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe) do |proc|
challenge = proc.output.gets_to_end
challenge = Base64.encode(challenge)
challenge = "data:image/png; base64, #{challenge}"
end
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}"
token = OpenSSL::HMAC.digest(:sha256, key, answer)
token = Base64.encode(token)
return {challenge: challenge, token: token}
end

@ -6,24 +6,51 @@
<div class="pure-u-1 pure-u-md-1-5"></div> <div class="pure-u-1 pure-u-md-1-5"></div>
<div class="pure-u-1 pure-u-md-3-5"> <div class="pure-u-1 pure-u-md-3-5">
<div class="h-box"> <div class="h-box">
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-2">
<a class="pure-button <% if account_type == "google" %>pure-button-disabled<% end %>" href="/login">Login to Google</a>
</div>
<div class="pure-u-1 pure-u-md-1-2">
<a class="pure-button <% if account_type == "invidious" %>pure-button-disabled<% end %>" href="/login?type=invidious">Login/Register</a>
</div>
</div>
<hr>
<% if account_type == "google" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= referer %>" method="post"> <form class="pure-form pure-form-stacked" action="/login?referer=<%= referer %>" method="post">
<fieldset> <fieldset>
<legend>Login to Google</legend>
<label for="email">Email</label> <label for="email">Email</label>
<input class="pure-input-1" name="email" type="email" placeholder="Email"> <input required class="pure-input-1" name="email" type="email" placeholder="Email">
<label for="password">Password</label> <label for="password">Password</label>
<input class="pure-input-1" name="password" type="password" placeholder="Password"> <input required class="pure-input-1" name="password" type="password" placeholder="Password">
<% if tfa %> <% if tfa %>
<label for="tfa">Google verification code</label> <label for="tfa">Google verification code</label>
<input class="pure-input-1" name="tfa" type="text" placeholder="Google verification code"> <input required class="pure-input-1" name="tfa" type="text" placeholder="Google verification code">
<% end %> <% end %>
<button type="submit" class="pure-button pure-button-primary">Sign in</button> <button type="submit" class="pure-button pure-button-primary">Sign in</button>
</fieldset> </fieldset>
</form> </form>
<% elsif account_type == "invidious" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= referer %>&type=invidious" method="post">
<fieldset>
<label for="email">User ID:</label>
<input required class="pure-input-1" name="email" type="text" placeholder="User ID">
<label for="password">Password</label>
<input required class="pure-input-1" name="password" type="password" placeholder="Password">
<img src='<%= captcha.not_nil![:challenge] %>'/>
<input type="hidden" name="token" value="<%= captcha.not_nil![:token] %>">
<label for="challenge_response">Time (hh:mm):</label>
<input required type="text" name="challenge_response" type="text>" placeholder="hh:mm">
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">Sign In</button>
<button type="submit" name="action" value="register" class="pure-button pure-button-primary">Register</button>
</fieldset>
</form>
<% end %>
</div> </div>
</div> </div>
<div class="pure-u-1 pure-u-md-1-5"></div> <div class="pure-u-1 pure-u-md-1-5"></div>

Loading…
Cancel
Save