invidious-copy-2022-08-14/src/invidious/users.cr

317 lines
11 KiB
Crystal
Raw Normal View History

2018-11-09 02:08:03 +00:00
require "crypto/bcrypt/password"
# Materialized views may not be defined using bound parameters (`$1` as used elsewhere)
2019-07-09 14:34:19 +00:00
MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" }
2019-03-29 21:30:02 +00:00
struct User
include DB::Serializable
property updated : Time
property notifications : Array(String)
property subscriptions : Array(String)
property email : String
@[DB::Field(converter: User::PreferencesConverter)]
property preferences : Preferences
property password : String?
property token : String
property watched : Array(String)
property feed_needs_update : Bool?
2018-08-04 20:30:44 +00:00
module PreferencesConverter
def self.from_rs(rs)
begin
Preferences.from_json(rs.read(String))
rescue ex
2019-03-28 18:43:40 +00:00
Preferences.from_json("{}")
2018-08-04 20:30:44 +00:00
end
end
end
end
def get_user(sid, headers, db, refresh = true)
if email = Invidious::Database::SessionIDs.select_email(sid)
2019-02-10 18:33:29 +00:00
user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
2018-08-04 20:30:44 +00:00
2019-06-08 00:56:41 +00:00
if refresh && Time.utc - user.updated > 1.minute
user, sid = fetch_user(sid, headers, db)
2018-08-04 20:30:44 +00:00
user_array = user.to_a
user_array[4] = user_array[4].to_json # User preferences
2018-08-04 20:30:44 +00:00
args = arg_array(user_array)
db.exec("INSERT INTO users VALUES (#{args}) \
2019-09-24 17:37:06 +00:00
ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array)
2019-02-10 18:33:29 +00:00
Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true)
begin
2019-04-11 00:56:38 +00:00
view_name = "subscriptions_#{sha256(user.email)}"
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
rescue ex
end
2018-08-04 20:30:44 +00:00
end
else
user, sid = fetch_user(sid, headers, db)
2018-08-04 20:30:44 +00:00
user_array = user.to_a
user_array[4] = user_array[4].to_json # User preferences
2018-08-04 20:30:44 +00:00
args = arg_array(user.to_a)
db.exec("INSERT INTO users VALUES (#{args}) \
2019-09-24 17:37:06 +00:00
ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array)
2019-02-10 18:33:29 +00:00
Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true)
begin
2019-04-11 00:56:38 +00:00
view_name = "subscriptions_#{sha256(user.email)}"
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
rescue ex
end
2018-08-04 20:30:44 +00:00
end
2019-02-10 18:33:29 +00:00
return user, sid
2018-08-04 20:30:44 +00:00
end
def fetch_user(sid, headers, db)
2019-10-25 16:58:16 +00:00
feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers)
2018-08-04 20:30:44 +00:00
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/")
2018-08-04 20:30:44 +00:00
end
end
channels = get_batch_channels(channels, db, false, false)
2018-08-04 20:30:44 +00:00
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({
updated: Time.utc,
notifications: [] of String,
subscriptions: channels,
email: email,
preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple),
password: nil,
token: token,
watched: [] of String,
feed_needs_update: true,
})
2019-02-10 18:33:29 +00:00
return user, sid
2018-08-04 20:30:44 +00:00
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({
updated: Time.utc,
notifications: [] of String,
subscriptions: [] of String,
email: email,
preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple),
password: password.to_s,
token: token,
watched: [] of String,
feed_needs_update: true,
})
2018-08-04 20:30:44 +00:00
2019-02-10 18:33:29 +00:00
return user, sid
2018-08-04 20:30:44 +00:00
end
2018-11-11 15:44:16 +00:00
def generate_captcha(key, db)
2018-11-26 00:26:21 +00:00
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
<svg viewBox="0 0 100 100" width="200px" height="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>
2018-11-26 00:26:21 +00:00
<line id="second" transform="rotate(#{second_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="12" fill="black" stroke="black" stroke-width="1"></line>
<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(%(rsvg-convert -w 400 -h 400 -b none -f 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
2018-11-26 00:26:21 +00:00
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}"
answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
2019-03-19 21:13:23 +00:00
return {
question: image,
2019-04-18 21:23:50 +00:00
tokens: {generate_response(answer, {":login"}, key, db, use_nonce: true)},
2019-03-19 21:13:23 +00:00
}
end
def generate_text_captcha(key, db)
response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body)
2019-03-19 21:13:23 +00:00
response = JSON.parse(response)
tokens = response["a"].as_a.map do |answer|
2019-04-18 21:23:50 +00:00
generate_response(answer.as_s, {":login"}, key, db, use_nonce: true)
2019-03-19 21:13:23 +00:00
end
2019-03-19 21:13:23 +00:00
return {
question: response["q"].as_s,
tokens: tokens,
}
end
2019-05-15 17:26:29 +00:00
def subscribe_ajax(channel_id, action, env_headers)
headers = HTTP::Headers.new
headers["Cookie"] = env_headers["Cookie"]
2019-10-25 16:58:16 +00:00
html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers)
2019-05-15 17:26:29 +00:00
2021-05-24 13:45:50 +00:00
cookies = HTTP::Cookies.from_client_headers(headers)
2019-05-15 17:26:29 +00:00
html.cookies.each do |cookie|
if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name
if cookies[cookie.name]?
cookies[cookie.name] = cookie
else
cookies << cookie
end
end
end
headers = cookies.add_request_headers(headers)
2020-06-15 22:33:23 +00:00
if match = html.body.match(/'XSRF_TOKEN': "(?<session_token>[^"]+)"/)
2019-05-15 17:26:29 +00:00
session_token = match["session_token"]
headers["content-type"] = "application/x-www-form-urlencoded"
post_req = {
2019-06-08 00:56:41 +00:00
session_token: session_token,
2019-05-15 17:26:29 +00:00
}
post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}"
2019-10-25 16:58:16 +00:00
YT_POOL.client &.post(post_url, headers, form: post_req)
2019-05-15 17:26:29 +00:00
end
end
2019-06-07 17:39:12 +00:00
def get_subscription_feed(db, user, max_results = 40, page = 1)
limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
offset = (page - 1) * limit
notifications = db.query_one("SELECT notifications FROM users WHERE email = $1", user.email,
as: Array(String))
view_name = "subscriptions_#{sha256(user.email)}"
if user.preferences.notifications_only && !notifications.empty?
# Only show notifications
notifications = Invidious::Database::ChannelVideos.select(notifications)
2019-06-07 17:39:12 +00:00
videos = [] of ChannelVideo
2021-09-25 02:42:43 +00:00
notifications.sort_by!(&.published).reverse!
2019-06-07 17:39:12 +00:00
case user.preferences.sort
when "alphabetically"
2021-09-25 02:42:43 +00:00
notifications.sort_by!(&.title)
2019-06-07 17:39:12 +00:00
when "alphabetically - reverse"
2021-09-25 02:42:43 +00:00
notifications.sort_by!(&.title).reverse!
2019-06-07 17:39:12 +00:00
when "channel name"
2021-09-25 02:42:43 +00:00
notifications.sort_by!(&.author)
2019-06-07 17:39:12 +00:00
when "channel name - reverse"
2021-09-25 02:42:43 +00:00
notifications.sort_by!(&.author).reverse!
else nil # Ignore
2019-06-07 17:39:12 +00:00
end
else
if user.preferences.latest_only
if user.preferences.unseen_only
# Show latest video from a channel that a user hasn't watched
# "unseen_only" isn't really correct here, more accurate would be "unwatched_only"
if user.watched.empty?
values = "'{}'"
else
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
end
2019-08-27 13:08:26 +00:00
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY ucid, published DESC", as: ChannelVideo)
2019-06-07 17:39:12 +00:00
else
# Show latest video from each channel
2019-08-27 13:08:26 +00:00
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo)
2019-06-07 17:39:12 +00:00
end
2021-09-25 02:42:43 +00:00
videos.sort_by!(&.published).reverse!
2019-06-07 17:39:12 +00:00
else
if user.preferences.unseen_only
# Only show unwatched
if user.watched.empty?
values = "'{}'"
else
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
end
2019-08-27 13:08:26 +00:00
videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
2019-06-07 17:39:12 +00:00
else
# Sort subscriptions as normal
2019-08-27 13:08:26 +00:00
videos = PG_DB.query_all("SELECT * FROM #{view_name} ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
2019-06-07 17:39:12 +00:00
end
end
case user.preferences.sort
when "published - reverse"
2021-09-25 02:42:43 +00:00
videos.sort_by!(&.published)
2019-06-07 17:39:12 +00:00
when "alphabetically"
2021-09-25 02:42:43 +00:00
videos.sort_by!(&.title)
2019-06-07 17:39:12 +00:00
when "alphabetically - reverse"
2021-09-25 02:42:43 +00:00
videos.sort_by!(&.title).reverse!
2019-06-07 17:39:12 +00:00
when "channel name"
2021-09-25 02:42:43 +00:00
videos.sort_by!(&.author)
2019-06-07 17:39:12 +00:00
when "channel name - reverse"
2021-09-25 02:42:43 +00:00
videos.sort_by!(&.author).reverse!
else nil # Ignore
2019-06-07 17:39:12 +00:00
end
2019-08-27 13:08:26 +00:00
notifications = PG_DB.query_one("SELECT notifications FROM users WHERE email = $1", user.email, as: Array(String))
2019-06-07 17:39:12 +00:00
notifications = videos.select { |v| notifications.includes? v.id }
videos = videos - notifications
end
return videos, notifications
end