require "crypto/bcrypt/password" struct User module PreferencesConverter def self.from_rs(rs) begin Preferences.from_json(rs.read(String)) rescue ex Preferences.from_json("{}") end end end db_mapping({ updated: Time, notifications: Array(String), subscriptions: Array(String), email: String, preferences: { type: Preferences, converter: PreferencesConverter, }, password: String?, token: String, watched: Array(String), }) end struct Preferences module StringToArray def self.to_json(value : Array(String), json : JSON::Builder) json.array do value.each do |element| json.string element end end 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 def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder) yaml.sequence do value.each do |element| yaml.scalar element end end end def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String) begin unless node.is_a?(YAML::Nodes::Sequence) node.raise "Expected sequence, not #{node.class}" end result = [] of String node.nodes.each do |item| unless item.is_a?(YAML::Nodes::Scalar) node.raise "Expected scalar, not #{item.class}" end result << item.value end rescue ex if node.is_a?(YAML::Nodes::Scalar) result = [node.value, ""] else result = ["", ""] end end result end end json_mapping({ autoplay: {type: Bool, default: CONFIG.default_user_preferences.autoplay}, captions: {type: Array(String), default: CONFIG.default_user_preferences.captions, converter: StringToArray}, comments: {type: Array(String), default: CONFIG.default_user_preferences.comments, converter: StringToArray}, continue: {type: Bool, default: CONFIG.default_user_preferences.continue}, dark_mode: {type: Bool, default: CONFIG.default_user_preferences.dark_mode}, latest_only: {type: Bool, default: CONFIG.default_user_preferences.latest_only}, listen: {type: Bool, default: CONFIG.default_user_preferences.listen}, local: {type: Bool, default: CONFIG.default_user_preferences.local}, locale: {type: String, default: CONFIG.default_user_preferences.locale}, max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results}, notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only}, quality: {type: String, default: CONFIG.default_user_preferences.quality}, redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed}, related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos}, sort: {type: String, default: CONFIG.default_user_preferences.sort}, speed: {type: Float32, default: CONFIG.default_user_preferences.speed}, thin_mode: {type: Bool, default: CONFIG.default_user_preferences.thin_mode}, unseen_only: {type: Bool, default: CONFIG.default_user_preferences.unseen_only}, video_loop: {type: Bool, default: CONFIG.default_user_preferences.video_loop}, volume: {type: Int32, default: CONFIG.default_user_preferences.volume}, }) end def get_user(sid, headers, db, refresh = true) if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User) if refresh && Time.now - user.updated > 1.minute user, sid = fetch_user(sid, headers, db) user_array = user.to_a user_array[4] = user_array[4].to_json args = arg_array(user_array) db.exec("INSERT INTO users VALUES (#{args}) \ ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array) db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \ ON CONFLICT (id) DO NOTHING", sid, user.email, Time.now) begin view_name = "subscriptions_#{sha256(user.email)[0..7]}" db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ SELECT * FROM channel_videos WHERE \ ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \ ORDER BY published DESC;") rescue ex end end else user, sid = fetch_user(sid, headers, db) user_array = user.to_a user_array[4] = user_array[4].to_json args = arg_array(user.to_a) db.exec("INSERT INTO users VALUES (#{args}) \ ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array) db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \ ON CONFLICT (id) DO NOTHING", sid, user.email, Time.now) begin view_name = "subscriptions_#{sha256(user.email)[0..7]}" db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ SELECT * FROM channel_videos WHERE \ ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \ ORDER BY published DESC;") rescue ex end end return user, sid end def fetch_user(sid, headers, db) client = make_client(YT_URL) feed = client.get("/subscription_manager?disable_polymer=1", headers) feed = XML.parse_html(feed.body) channels = [] of String channels = feed.xpath_nodes(%q(//ul[@id="guide-channels"]/li/a)).compact_map do |channel| if {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? channel["title"] nil else channel["href"].lstrip("/channel/") end end channels = get_batch_channels(channels, db, false, false) 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)) user = User.new(Time.now, [] of String, channels, email, CONFIG.default_user_preferences, nil, token, [] of String) return user, sid end def create_user(sid, email, password) password = Crypto::Bcrypt::Password.create(password, cost: 10) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) user = User.new(Time.now, [] of String, [] of String, email, CONFIG.default_user_preferences, password.to_s, token, [] of String) return user, sid end def create_response(user_id, operation, key, db, expire = 6.hours) expire = Time.now + expire nonce = Random::Secure.hex(16) db.exec("INSERT INTO nonces VALUES ($1, $2) ON CONFLICT DO NOTHING", nonce, expire) 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 def sign_token(key, hash) string_to_sign = [] of String hash.each do |key, value| if key == "signature" next end case value when Array string_to_sign << "#{key}=#{value.sort.join(",")}" else string_to_sign << "#{key}=#{value}" end end string_to_sign = string_to_sign.sort.join("\n") return Base64.encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip end def validate_response(challenge, token, user_id, operation, key, db, locale) if !challenge raise translate(locale, "Hidden field \"challenge\" is a required field") end if !token raise translate(locale, "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 translate(locale, "Invalid challenge") end challenge = OpenSSL::HMAC.digest(:sha256, key, challenge) challenge = Base64.urlsafe_encode(challenge) if nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", nonce, as: {String, Time}) if nonce[1] > Time.now db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.new(1990, 1, 1), nonce[0]) else raise translate(locale, "Invalid token") end else raise translate(locale, "Invalid token") end if challenge != token raise translate(locale, "Invalid token") end if challenge_operation != operation raise translate(locale, "Invalid token") end if challenge_user_id != user_id raise translate(locale, "Invalid token") end if expire < Time.now.to_unix raise translate(locale, "Token is expired, please try again") end end def generate_captcha(key, db) second = Random::Secure.rand(12) second_angle = second * 30 second = second * 5 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 1 2 3 4 5 6 7 8 9 10 11 12 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')}:#{second.to_s.rjust(2, '0')}" answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer) return { question: image, tokens: [create_response(answer, "sign_in", key, db)], } end def generate_text_captcha(key, db) response = HTTP::Client.get(TEXTCAPTCHA_URL).body response = JSON.parse(response) tokens = response["a"].as_a.map do |answer| create_response(answer.as_s, "sign_in", key, db) end return { question: response["q"].as_s, tokens: tokens, } end