2018-08-04 20:30:44 +00:00
|
|
|
class User
|
|
|
|
module PreferencesConverter
|
|
|
|
def self.from_rs(rs)
|
|
|
|
begin
|
|
|
|
Preferences.from_json(rs.read(String))
|
|
|
|
rescue ex
|
|
|
|
DEFAULT_USER_PREFERENCES
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
add_mapping({
|
2018-08-15 17:40:42 +00:00
|
|
|
id: Array(String),
|
2018-08-04 20:30:44 +00:00
|
|
|
updated: Time,
|
|
|
|
notifications: Array(String),
|
|
|
|
subscriptions: Array(String),
|
|
|
|
email: String,
|
|
|
|
preferences: {
|
|
|
|
type: Preferences,
|
|
|
|
default: DEFAULT_USER_PREFERENCES,
|
|
|
|
converter: PreferencesConverter,
|
|
|
|
},
|
|
|
|
password: String?,
|
|
|
|
token: String,
|
|
|
|
watched: Array(String),
|
|
|
|
})
|
|
|
|
end
|
|
|
|
|
|
|
|
DEFAULT_USER_PREFERENCES = Preferences.from_json({
|
2018-08-30 21:49:38 +00:00
|
|
|
"video_loop" => false,
|
|
|
|
"autoplay" => false,
|
|
|
|
"speed" => 1.0,
|
|
|
|
"quality" => "hd720",
|
|
|
|
"volume" => 100,
|
|
|
|
"comments" => ["youtube", ""],
|
|
|
|
"captions" => ["", "", ""],
|
|
|
|
"related_videos" => true,
|
|
|
|
"dark_mode" => false,
|
2018-09-21 16:06:35 +00:00
|
|
|
"thin_mode" => false,
|
2018-08-30 21:49:38 +00:00
|
|
|
"max_results" => 40,
|
|
|
|
"sort" => "published",
|
|
|
|
"latest_only" => false,
|
|
|
|
"unseen_only" => false,
|
2018-08-04 20:30:44 +00:00
|
|
|
}.to_json)
|
|
|
|
|
|
|
|
class Preferences
|
2018-08-25 23:33:15 +00:00
|
|
|
module StringToArray
|
|
|
|
def self.to_json(value : Array(String), json : JSON::Builder)
|
2018-08-26 02:33:53 +00:00
|
|
|
json.array do
|
|
|
|
value.each do |element|
|
|
|
|
json.string element
|
|
|
|
end
|
|
|
|
end
|
2018-08-25 23:33:15 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.from_json(value : JSON::PullParser) : Array(String)
|
|
|
|
begin
|
|
|
|
result = [] of String
|
|
|
|
value.read_array do
|
|
|
|
result << value.read_string
|
|
|
|
end
|
|
|
|
rescue ex
|
|
|
|
result = [value.read_string, ""]
|
|
|
|
end
|
|
|
|
|
|
|
|
result
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-08-04 20:30:44 +00:00
|
|
|
JSON.mapping({
|
|
|
|
video_loop: Bool,
|
|
|
|
autoplay: Bool,
|
2018-11-11 17:45:05 +00:00
|
|
|
continue: {
|
|
|
|
type: Bool,
|
|
|
|
default: false,
|
|
|
|
},
|
|
|
|
listen: {
|
2018-10-30 14:41:23 +00:00
|
|
|
type: Bool,
|
|
|
|
default: false,
|
|
|
|
},
|
|
|
|
speed: Float32,
|
|
|
|
quality: String,
|
|
|
|
volume: Int32,
|
|
|
|
comments: {
|
2018-08-25 23:33:15 +00:00
|
|
|
type: Array(String),
|
|
|
|
default: ["youtube", ""],
|
|
|
|
converter: StringToArray,
|
2018-08-04 20:30:44 +00:00
|
|
|
},
|
2018-08-06 18:23:36 +00:00
|
|
|
captions: {
|
|
|
|
type: Array(String),
|
|
|
|
default: ["", "", ""],
|
|
|
|
},
|
2018-08-04 20:30:44 +00:00
|
|
|
redirect_feed: {
|
|
|
|
type: Bool,
|
|
|
|
default: false,
|
|
|
|
},
|
2018-08-30 21:49:38 +00:00
|
|
|
related_videos: {
|
|
|
|
type: Bool,
|
|
|
|
default: true,
|
|
|
|
},
|
2018-08-04 20:30:44 +00:00
|
|
|
dark_mode: Bool,
|
|
|
|
thin_mode: {
|
|
|
|
type: Bool,
|
|
|
|
default: false,
|
|
|
|
},
|
|
|
|
max_results: Int32,
|
|
|
|
sort: String,
|
|
|
|
latest_only: Bool,
|
|
|
|
unseen_only: Bool,
|
|
|
|
notifications_only: {
|
|
|
|
type: Bool,
|
|
|
|
default: false,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
end
|
|
|
|
|
|
|
|
def get_user(sid, client, headers, db, refresh = true)
|
2018-08-15 17:40:42 +00:00
|
|
|
if db.query_one?("SELECT EXISTS (SELECT true FROM users WHERE $1 = ANY(id))", sid, as: Bool)
|
|
|
|
user = db.query_one("SELECT * FROM users WHERE $1 = ANY(id)", sid, as: User)
|
2018-08-04 20:30:44 +00:00
|
|
|
|
|
|
|
if refresh && Time.now - user.updated > 1.minute
|
|
|
|
user = fetch_user(sid, client, headers, db)
|
|
|
|
user_array = user.to_a
|
|
|
|
|
|
|
|
user_array[5] = user_array[5].to_json
|
|
|
|
args = arg_array(user_array)
|
|
|
|
|
|
|
|
db.exec("INSERT INTO users VALUES (#{args}) \
|
2018-08-15 17:40:42 +00:00
|
|
|
ON CONFLICT (email) DO UPDATE SET id = users.id || $1, updated = $2, subscriptions = $4", user_array)
|
2018-10-10 21:10:58 +00:00
|
|
|
|
|
|
|
begin
|
|
|
|
view_name = "subscriptions_#{sha256(user.email)[0..7]}"
|
|
|
|
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
|
|
|
|
SELECT * FROM channel_videos WHERE \
|
|
|
|
ucid = ANY ((SELECT subscriptions FROM users WHERE email = '#{user.email}')::text[]) \
|
|
|
|
ORDER BY published DESC;")
|
|
|
|
rescue ex
|
|
|
|
end
|
2018-08-04 20:30:44 +00:00
|
|
|
end
|
|
|
|
else
|
|
|
|
user = fetch_user(sid, client, headers, db)
|
|
|
|
user_array = user.to_a
|
|
|
|
|
|
|
|
user_array[5] = user_array[5].to_json
|
|
|
|
args = arg_array(user.to_a)
|
|
|
|
|
|
|
|
db.exec("INSERT INTO users VALUES (#{args}) \
|
2018-08-15 17:40:42 +00:00
|
|
|
ON CONFLICT (email) DO UPDATE SET id = users.id || $1, updated = $2, subscriptions = $4", user_array)
|
2018-10-10 21:10:58 +00:00
|
|
|
|
|
|
|
begin
|
|
|
|
view_name = "subscriptions_#{sha256(user.email)[0..7]}"
|
|
|
|
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
|
|
|
|
SELECT * FROM channel_videos WHERE \
|
|
|
|
ucid = ANY ((SELECT subscriptions FROM users WHERE email = '#{user.email}')::text[]) \
|
|
|
|
ORDER BY published DESC;")
|
|
|
|
rescue ex
|
|
|
|
end
|
2018-08-04 20:30:44 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
return user
|
|
|
|
end
|
|
|
|
|
|
|
|
def fetch_user(sid, client, headers, db)
|
|
|
|
feed = client.get("/subscription_manager?disable_polymer=1", headers)
|
|
|
|
feed = XML.parse_html(feed.body)
|
|
|
|
|
|
|
|
channels = [] of String
|
|
|
|
feed.xpath_nodes(%q(//ul[@id="guide-channels"]/li/a)).each do |channel|
|
2018-09-09 13:53:04 +00:00
|
|
|
if !{"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? channel["title"]
|
2018-08-04 20:30:44 +00:00
|
|
|
channel_id = channel["href"].lstrip("/channel/")
|
|
|
|
|
|
|
|
begin
|
|
|
|
channel = get_channel(channel_id, client, db, false, false)
|
|
|
|
channels << channel.id
|
|
|
|
rescue ex
|
|
|
|
next
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"]))
|
|
|
|
if email
|
|
|
|
email = email.content.strip
|
|
|
|
else
|
|
|
|
email = ""
|
|
|
|
end
|
|
|
|
|
|
|
|
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
|
|
|
|
2018-08-15 17:40:42 +00:00
|
|
|
user = User.new([sid], Time.now, [] of String, channels, email, DEFAULT_USER_PREFERENCES, nil, token, [] of String)
|
2018-08-04 20:30:44 +00:00
|
|
|
return user
|
|
|
|
end
|
|
|
|
|
|
|
|
def create_user(sid, email, password)
|
|
|
|
password = Crypto::Bcrypt::Password.create(password, cost: 10)
|
|
|
|
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
|
|
|
|
2018-08-15 17:40:42 +00:00
|
|
|
user = User.new([sid], Time.now, [] of String, [] of String, email, DEFAULT_USER_PREFERENCES, password.to_s, token, [] of String)
|
2018-08-04 20:30:44 +00:00
|
|
|
|
|
|
|
return user
|
|
|
|
end
|
2018-11-11 15:44:16 +00:00
|
|
|
|
2018-11-17 19:18:12 +00:00
|
|
|
def create_response(user_id, operation, key, db, expire = 6.hours)
|
2018-11-11 15:44:16 +00:00
|
|
|
expire = Time.now + expire
|
2018-11-17 19:18:12 +00:00
|
|
|
nonce = Random::Secure.hex(16)
|
|
|
|
db.exec("INSERT INTO nonces VALUES ($1) ON CONFLICT DO NOTHING", nonce)
|
2018-11-11 15:44:16 +00:00
|
|
|
|
|
|
|
challenge = "#{expire.to_unix}-#{nonce}-#{user_id}-#{operation}"
|
|
|
|
token = OpenSSL::HMAC.digest(:sha256, key, challenge)
|
|
|
|
|
|
|
|
challenge = Base64.urlsafe_encode(challenge)
|
|
|
|
token = Base64.urlsafe_encode(token)
|
|
|
|
|
|
|
|
return challenge, token
|
|
|
|
end
|
|
|
|
|
2018-11-17 19:18:12 +00:00
|
|
|
def validate_response(challenge, token, user_id, operation, key, db)
|
2018-11-11 15:44:16 +00:00
|
|
|
if !challenge
|
|
|
|
raise "Hidden field \"challenge\" is a required field"
|
|
|
|
end
|
|
|
|
|
|
|
|
if !token
|
|
|
|
raise "Hidden field \"token\" is a required field"
|
|
|
|
end
|
|
|
|
|
|
|
|
challenge = Base64.decode_string(challenge)
|
|
|
|
if challenge.split("-").size == 4
|
|
|
|
expire, nonce, challenge_user_id, challenge_operation = challenge.split("-")
|
|
|
|
|
|
|
|
expire = expire.to_i?
|
|
|
|
expire ||= 0
|
|
|
|
else
|
|
|
|
raise "Invalid challenge"
|
|
|
|
end
|
|
|
|
|
|
|
|
challenge = OpenSSL::HMAC.digest(:sha256, HMAC_KEY, challenge)
|
|
|
|
challenge = Base64.urlsafe_encode(challenge)
|
|
|
|
|
2018-11-17 19:18:12 +00:00
|
|
|
if db.query_one?("SELECT EXISTS (SELECT true FROM nonces WHERE nonce = $1)", nonce, as: Bool)
|
|
|
|
db.exec("DELETE FROM nonces * WHERE nonce = $1", nonce)
|
|
|
|
else
|
|
|
|
raise "Invalid token"
|
|
|
|
end
|
|
|
|
|
2018-11-11 15:44:16 +00:00
|
|
|
if challenge != token
|
|
|
|
raise "Invalid token"
|
|
|
|
end
|
|
|
|
|
|
|
|
if challenge_operation != operation
|
|
|
|
raise "Invalid token"
|
|
|
|
end
|
|
|
|
|
|
|
|
if challenge_user_id != user_id
|
|
|
|
raise "Invalid token"
|
|
|
|
end
|
|
|
|
|
|
|
|
if expire < Time.now.to_unix
|
|
|
|
raise "Token is expired, please try again"
|
|
|
|
end
|
|
|
|
end
|
2018-11-17 19:18:12 +00:00
|
|
|
|
|
|
|
def generate_captcha(key, db)
|
|
|
|
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>
|
|
|
|
|
|
|
|
<text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text>
|
|
|
|
<text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text>
|
|
|
|
<text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text>
|
|
|
|
<text x="82.909" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 4</text>
|
|
|
|
<text x="69" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 5</text>
|
|
|
|
<text x="50" y="91" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 6</text>
|
|
|
|
<text x="31" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 7</text>
|
|
|
|
<text x="17.091" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 8</text>
|
|
|
|
<text x="12" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 9</text>
|
|
|
|
<text x="17.091" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">10</text>
|
|
|
|
<text x="31" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">11</text>
|
|
|
|
<text x="50" y="15" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">12</text>
|
|
|
|
|
|
|
|
<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
|
|
|
|
|
|
|
|
image = ""
|
|
|
|
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|
|
|
|
|
image = proc.output.gets_to_end
|
|
|
|
image = Base64.strict_encode(image)
|
|
|
|
image = "data:image/png;base64,#{image}"
|
|
|
|
end
|
|
|
|
|
|
|
|
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}"
|
|
|
|
answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
|
|
|
|
|
|
|
|
challenge, token = create_response(answer, "sign_in", key, db)
|
|
|
|
|
|
|
|
return {image: image, challenge: challenge, token: token}
|
|
|
|
end
|