Connect Google and add a YouTube playlist source
A settings page connects the user's Google account through OAuth and
keeps its refresh token. A YouTubePlaylist list follows one of their
playlists, private ones included: the new-list form takes its URL, and
the sync dialog fetches it again, showing a spinner while it works.
Each source kind now renders its own new-list fields and sets the list
up from them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG4iASZnHb1yEM1JAASiWn
diff --git a/AGENTS.md b/AGENTS.md
index 22245d6..0be41c2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,7 +24,8 @@ db/migrate/ — Sequel migrations
lib/rank_king/ — app code
config.rb — Config data class, reads from ENV
db.rb — Sequel connection + migration runner
- models.rb — Sequel models (User, List, Item, Axis, Result)
+ models.rb — Sequel models (User, GoogleAccount, List, Item, Axis, Result)
+ google.rb — Google OAuth and the YouTube Data API, over a swappable transport
sources.rb — Sources.fetch: a list's source_kind names a class in Sources
sources/ — one class per source kind
sync.rb — reconciles a list's items with a fresh snapshot from a fetching source
@@ -39,7 +40,7 @@ Steepfile — type checking config
public/ — static assets
vendor/gloss/ — vendored Gloss CSS and JS (refresh with rake gloss:vendor)
css/app.css — Rank King's layout over Gloss
- js/app.js — axis tags and delete confirmation
+ js/app.js — axis tags, confirmations, busy buttons, new-list source fields
.github/workflows/ — CI, PR previews, preview reaper (GitHub Actions)
Dockerfile — production image
fly.toml — Fly config for the main demo and PR previews
@@ -48,8 +49,9 @@ fly.toml — Fly config for the main demo and PR previews
## Domain model
-- **User** has many Lists; identified by the login on the `Remote-User` header
+- **User** has many Lists; identified by the login on the `Remote-User` header. Their settings page (`/settings`) connects a Google account (**GoogleAccount**, which keeps only the refresh token)
- **List** belongs to a User and has many Items and Axes, and one source (`source_kind` names a class in `Sources`). Each source renders its own sync dialog on the list page and applies what it posts: `Text`'s edits items by id, and a kind that fetches calls `Sync.call`. A fetching kind keeps its config in its own table keyed by `list_id` and its per-item details in a table keyed by `item_id`
+- Sources: `Text` (typed in, edited by id) and `YouTubePlaylist` (follows one of the owner's playlists through their Google account; `youtube_playlist_sources` by `list_id`, `youtube_playlist_items` by `item_id`)
- **Item** belongs to a List; removed items, and items missing from the latest sync, are archived (`archived_at`) and hidden everywhere
- **Axis** belongs to a List, has many Results
- **Result** belongs to an Axis, a winner (Item), and a loser (Item)
@@ -82,8 +84,15 @@ fly.toml — Fly config for the main demo and PR previews
- `DATABASE_URL` — SQLite path (default: `:memory:`)
- `SESSION_SECRET` — session signing key (auto-generated if unset)
+- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` — a Google OAuth web client
+ with the YouTube Data API enabled, for connecting Google. Its redirect
+ URI is `<app URL>/settings/google/callback`. Without them YouTube lists
+ are not offered. A consent screen left in Testing mode expires refresh
+ tokens after 7 days.
-Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`.
+Tests set `DATABASE_URL=:memory:` and a dummy Google client in
+`test/test_helper.rb`, and swap `RankKing::Google.transport` so no
+request leaves them.
## Conventions
@@ -102,7 +111,9 @@ Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`.
rather than a new button or screen.
- A list's source is fixed when it is made. `Sources::KINDS` lists the
kinds the new-list form offers, and each kind's `title` names it there
- and in settings. Its sync dialog opens from the list's ⋯ menu.
+ and in settings. Its sync dialog opens from the list's ⋯ menu. Each
+ kind also renders its own fields on the new-list form (`fields`) and
+ sets the new list up from them (`create`).
- Every request needs a `Remote-User` header or it gets a 401. That is safe only because a proxy (Tailscale identity) writes the header. `StubLogin` fills it in for `demo.ru` (dev server, Fly apps) and nowhere else: never in `config.ru`, and never in front of real data.
- Lists are private to their owner (404 for anyone else)
- Config is a frozen `Data.define` value object
diff --git a/Steepfile b/Steepfile
index ae41aec..4ef2b3c 100644
--- a/Steepfile
+++ b/Steepfile
@@ -10,5 +10,5 @@ target :lib do
ignore "lib/rank_king/views", inline: true
signature "sig"
- library "securerandom"
+ library "securerandom", "net-http", "json", "uri"
end
diff --git a/db/migrate/005_add_youtube.rb b/db/migrate/005_add_youtube.rb
new file mode 100644
index 0000000..6ab0207
--- /dev/null
+++ b/db/migrate/005_add_youtube.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ change do
+ # A user's connection to Google, for reading their YouTube playlists.
+ # Only the refresh token is kept: each sync trades it for an access
+ # token.
+ create_table(:google_accounts) do
+ primary_key :id
+ foreign_key :user_id, :users, null: false, unique: true, on_delete: :cascade
+ String :refresh_token, null: false
+ DateTime :created_at, null: false
+ DateTime :updated_at, null: false
+ end
+
+ # The YouTubePlaylist source's config: which playlist a list follows.
+ create_table(:youtube_playlist_sources) do
+ foreign_key :list_id, :lists, primary_key: true, on_delete: :cascade
+ String :playlist_id, null: false
+ end
+
+ # The video behind each item of a YouTubePlaylist list, which is how
+ # a sync recognizes the item again.
+ create_table(:youtube_playlist_items) do
+ foreign_key :item_id, :items, primary_key: true, on_delete: :cascade
+ String :video_id, null: false
+ end
+ end
+end
diff --git a/lib/rank_king/config.rb b/lib/rank_king/config.rb
index efbcda8..89124f1 100644
--- a/lib/rank_king/config.rb
+++ b/lib/rank_king/config.rb
@@ -7,6 +7,8 @@ module RankKing
Config = Data.define(
:database_url,
:session_secret,
+ :google_client_id,
+ :google_client_secret,
)
class Config
@@ -20,6 +22,10 @@ module RankKing
new(
database_url: env.fetch("DATABASE_URL", ":memory:"),
session_secret: env.fetch("SESSION_SECRET") { SecureRandom.hex(64) },
+ # A Google OAuth client, for connecting YouTube. Without one the
+ # settings page says so and YouTube lists cannot be made.
+ google_client_id: env["GOOGLE_CLIENT_ID"],
+ google_client_secret: env["GOOGLE_CLIENT_SECRET"],
)
end
end
diff --git a/lib/rank_king/google.rb b/lib/rank_king/google.rb
new file mode 100644
index 0000000..8c13bb2
--- /dev/null
+++ b/lib/rank_king/google.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require "json"
+require "net/http"
+require "uri"
+
+require_relative "config"
+
+module RankKing
+ # The Google APIs rank-king calls: OAuth, to connect a user's account,
+ # and the YouTube Data API, to read their playlists.
+ module Google
+ # Google refused a request, or answered with something unexpected.
+ class Error < StandardError; end
+
+ AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
+ TOKEN_URL = "https://oauth2.googleapis.com/token"
+ PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems"
+ SCOPE = "https://www.googleapis.com/auth/youtube.readonly"
+
+ # Makes one request and returns its JSON body: a POST of a form, or
+ # a GET with a query, authorized by the access token when there is
+ # one. Tests swap it for canned answers.
+ singleton_class.attr_writer :transport
+
+ #: () -> ^(Symbol, String, Hash[Symbol, String], String?) -> Hash[String, untyped]
+ def self.transport
+ @transport ||= ->(method, url, params, access_token) { request(method, url, params, access_token) }
+ end
+
+ #: () -> bool
+ def self.configured? = !!(CONFIG.google_client_id && CONFIG.google_client_secret)
+
+ # Where to send the user to grant read access to their YouTube
+ # account. prompt=consent makes Google send a refresh token every time.
+ #: (redirect_uri: String, state: String) -> String
+ def self.authorize_url(redirect_uri:, state:)
+ query = URI.encode_www_form(
+ client_id: CONFIG.google_client_id, redirect_uri:, state:, scope: SCOPE,
+ response_type: "code", access_type: "offline", prompt: "consent",
+ )
+ "#{AUTH_URL}?#{query}"
+ end
+
+ # Trades the code Google sent back for a refresh token.
+ #: (String code, redirect_uri: String) -> String
+ def self.refresh_token(code, redirect_uri:)
+ body = transport.call(:post, TOKEN_URL, { **client, code:, redirect_uri:, grant_type: "authorization_code" }, nil)
+ body.fetch("refresh_token") { raise Error, "Google sent no refresh token" }
+ end
+
+ #: (String refresh_token) -> String
+ def self.access_token(refresh_token)
+ body = transport.call(:post, TOKEN_URL, { **client, refresh_token:, grant_type: "refresh_token" }, nil)
+ body.fetch("access_token") { raise Error, "Google sent no access token" }
+ end
+
+ # The videos in a playlist, in playlist order, as [video id, title].
+ # Deleted videos, and private ones the user cannot see, carry no
+ # owner and are left out.
+ #: (String access_token, String playlist_id) -> Array[[String, String]]
+ def self.playlist_videos(access_token, playlist_id)
+ videos = [] #: Array[[String, String]]
+ page = nil #: String?
+ loop do
+ query = { part: "snippet", playlistId: playlist_id, maxResults: "50" } #: Hash[Symbol, String]
+ query[:pageToken] = page if page
+ body = transport.call(:get, PLAYLIST_ITEMS_URL, query, access_token)
+
+ items = body["items"] || [] #: Array[Hash[String, untyped]]
+ items.each do |item|
+ snippet = item.fetch("snippet")
+ next unless snippet["videoOwnerChannelTitle"]
+
+ videos << [snippet.dig("resourceId", "videoId"), snippet.fetch("title")]
+ end
+
+ page = body["nextPageToken"]
+ break unless page
+ end
+ videos
+ end
+
+ #: () -> Hash[Symbol, String]
+ def self.client
+ { client_id: CONFIG.google_client_id.to_s, client_secret: CONFIG.google_client_secret.to_s }
+ end
+
+ #: (Symbol method, String url, Hash[Symbol, String] params, String? access_token) -> Hash[String, untyped]
+ def self.request(method, url, params, access_token)
+ uri = URI(url)
+ request = if method == :post
+ Net::HTTP::Post.new(uri).tap { |post| post.set_form_data(params) }
+ else
+ uri.query = URI.encode_www_form(params)
+ Net::HTTP::Get.new(uri)
+ end
+ request["Authorization"] = "Bearer #{access_token}" if access_token
+
+ response = Net::HTTP.start(uri.hostname.to_s, uri.port, use_ssl: true) { |http| http.request(request) }
+ body = JSON.parse(response.body.to_s)
+ return body if response.is_a?(Net::HTTPSuccess)
+
+ error = body["error"]
+ message = error.is_a?(Hash) ? error["message"] : body["error_description"] || error
+ raise Error, message || "HTTP #{response.code}"
+ rescue JSON::ParserError
+ raise Error, "Google answered HTTP #{response&.code} with no JSON"
+ end
+ end
+end
diff --git a/lib/rank_king/models.rb b/lib/rank_king/models.rb
index 0e2dca8..3865b10 100644
--- a/lib/rank_king/models.rb
+++ b/lib/rank_king/models.rb
@@ -10,6 +10,12 @@ module RankKing
# @rbs skip
class User < Sequel::Model(DB[:users])
one_to_many :lists, class: "RankKing::List"
+ one_to_one :google_account, class: "RankKing::GoogleAccount"
+ end
+
+ # @rbs skip
+ class GoogleAccount < Sequel::Model(DB[:google_accounts])
+ many_to_one :user, class: "RankKing::User"
end
# @rbs skip
diff --git a/lib/rank_king/sources.rb b/lib/rank_king/sources.rb
index fcf89e1..3be114e 100644
--- a/lib/rank_king/sources.rb
+++ b/lib/rank_king/sources.rb
@@ -12,12 +12,13 @@ module RankKing
end
require_relative "sources/text"
+require_relative "sources/youtube_playlist"
module RankKing
module Sources
# Every kind a list can be made from, in the order the new-list form
# offers them. A list keeps its kind for good.
- KINDS = %w[Text].freeze #: Array[String]
+ KINDS = %w[Text YouTubePlaylist].freeze #: Array[String]
# A list's source_kind names a class in this module.
#: (String kind) -> _SourceClass
diff --git a/lib/rank_king/sources/text.rb b/lib/rank_king/sources/text.rb
index e4a5257..7445d07 100644
--- a/lib/rank_king/sources/text.rb
+++ b/lib/rank_king/sources/text.rb
@@ -14,6 +14,20 @@ module RankKing
#: () -> String
def self.title = "Typed list"
+ #: (User user) -> _Component
+ def self.fields(user) = Views::Sources::TextFields.new
+
+ # Adds the items typed into the new-list form, of which there must
+ # be at least two.
+ #: (List list, Hash[String, untyped] params) -> bool
+ def self.create(list, params)
+ names = params["items"].to_s.lines.map(&:strip).reject(&:empty?)
+ return false if names.size < 2
+
+ names.each { |name| Item.create(list_id: list.id, name:) }
+ true
+ end
+
#: (List list) -> void
def initialize(list)
@list = list
diff --git a/lib/rank_king/sources/youtube_playlist.rb b/lib/rank_king/sources/youtube_playlist.rb
new file mode 100644
index 0000000..decf4d1
--- /dev/null
+++ b/lib/rank_king/sources/youtube_playlist.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require_relative "../google"
+require_relative "../sync"
+require_relative "../views/sources/youtube_playlist"
+
+module RankKing
+ module Sources
+ # A list that follows one of the owner's YouTube playlists, read
+ # through their connected Google account, so private ones work too.
+ # Each sync fetches the whole playlist again.
+ class YouTubePlaylist
+ # Which playlist each list follows, keyed by list_id.
+ TABLE = :youtube_playlist_sources
+ # Which video each item is, keyed by item_id.
+ ITEMS = :youtube_playlist_items
+
+ #: () -> String
+ def self.title = "YouTube playlist"
+
+ #: (List list) -> YouTubePlaylist
+ def self.for(list) = new(list)
+
+ #: (User user) -> _Component
+ def self.fields(user)
+ Views::Sources::YouTubePlaylistFields.new(connected: !user.google_account.nil?, configured: Google.configured?)
+ end
+
+ # A playlist's id, from its URL or as pasted on its own.
+ #: (String text) -> String?
+ def self.playlist_id(text)
+ text = text.strip
+ text[/[?&]list=([\w-]+)/, 1] || text[/\A[\w-]+\z/]
+ end
+
+ # Follows the playlist from the new-list form and fetches it.
+ #: (List list, Hash[String, untyped] params) -> bool
+ def self.create(list, params)
+ playlist_id = playlist_id(params["playlist"].to_s)
+ return false unless playlist_id && list.user.google_account
+
+ DB[TABLE].insert(list_id: list.id, playlist_id:)
+ new(list).fetch
+ true
+ end
+
+ #: (List list) -> void
+ def initialize(list)
+ @list = list
+ end
+
+ #: () -> String
+ def label = "Sync from YouTube"
+
+ #: (csrf: Method) -> _Component
+ def form(csrf:) = Views::Sources::YouTubePlaylist.new(list: @list, csrf:)
+
+ # The dialog posts nothing to apply: syncing is fetching.
+ #: (Hash[String, untyped] params) -> void
+ def sync(params)
+ fetch
+ end
+
+ #: () -> Sync::Summary
+ def fetch
+ account = @list.user.google_account
+ raise Google::Error, "Connect Google in settings to sync this list" unless account
+
+ access_token = Google.access_token(account.refresh_token)
+ videos = Google.playlist_videos(access_token, playlist_id).uniq { |video_id, _| video_id }
+ entries = videos.map { |video_id, title| Entry.new(key: video_id, name: title) }
+ Sync.call(@list, entries, source: self)
+ end
+
+ #: () -> String
+ def playlist_id = DB[TABLE].where(list_id: @list.id).get(:playlist_id).to_s
+
+ #: () -> Hash[String, Item]
+ def items_by_key
+ video_ids = DB[ITEMS].where(item_id: Item.where(list_id: @list.id).select(:id)).to_hash(:item_id, :video_id)
+ Item.where(id: video_ids.keys).all.to_h do |item|
+ [video_ids.fetch(item.id).to_s, item] #: [String, Item]
+ end
+ end
+
+ #: (Item item, Entry entry) -> void
+ def saved(item, entry)
+ DB[ITEMS].replace(item_id: item.id, video_id: entry.key)
+ end
+ end
+ end
+end
diff --git a/lib/rank_king/views/home.rb b/lib/rank_king/views/home.rb
index 1a9d8eb..9302a29 100644
--- a/lib/rank_king/views/home.rb
+++ b/lib/rank_king/views/home.rb
@@ -14,6 +14,8 @@ module RankKing
def view_template
render Layout.new do
render PageHeader.new(title: "Rank King") do
+ a(href: "/settings", class: "icon-button", aria_label: "Settings",
+ style: "--gl-icon: var(--gl-icon-settings)")
a(href: "/lists/new", class: "btn", data_variant: "primary", data_size: "sm",
style: "--gl-icon: var(--gl-icon-plus)") { "New list" }
end
diff --git a/lib/rank_king/views/list/new.rb b/lib/rank_king/views/list/new.rb
index 8ee0ee5..6bb5ba3 100644
--- a/lib/rank_king/views/list/new.rb
+++ b/lib/rank_king/views/list/new.rb
@@ -29,18 +29,16 @@ module RankKing
label(class: "field") do
plain "Source"
select(name: "source") do
- @sources.each { |value, name| option(value: value) { name } }
+ @sources.each { |kind, title, _fields| option(value: kind) { title } }
end
small { "A list keeps its source." }
end
- # The chosen source's own fields. Each source gets one of these.
- fieldset(data_source: "Text") do
- label(class: "field") do
- plain "Items"
- textarea(name: "items", rows: 6, required: true)
- small { "One per line, at least two." }
- end
+ # Each source's own fields. Only the chosen source's are
+ # enabled, so only they are checked and posted; app.js
+ # switches them when the dropdown changes.
+ @sources.each_with_index do |(kind, _title, fields), index|
+ fieldset(data_source: kind, disabled: index > 0, hidden: index > 0) { render fields }
end
render AxisTags.new
diff --git a/lib/rank_king/views/settings.rb b/lib/rank_king/views/settings.rb
new file mode 100644
index 0000000..f0835f6
--- /dev/null
+++ b/lib/rank_king/views/settings.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "layout"
+
+module RankKing
+ module Views
+ # The user's own settings, as against a list's. A future setting is a
+ # new section here.
+ class Settings < Phlex::HTML
+ def initialize(user:, google_configured:, csrf:)
+ @user = user
+ @google_configured = google_configured
+ @csrf = csrf
+ end
+
+ def view_template
+ render Layout.new(title: "Settings — Rank King") do
+ render PageHeader.new(title: "Settings", back: "/", back_label: "Lists")
+
+ section do
+ h2(class: "type-label") { "Google" }
+ if @user.google_account
+ render_connected
+ elsif @google_configured
+ p { "Connect Google to make lists from your YouTube playlists, private ones included." }
+ a(href: "/settings/google/connect", class: "btn", data_variant: "primary") { "Connect Google" }
+ else
+ p { "This server has no Google client set up. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to connect Google." }
+ end
+ end
+ end
+ end
+
+ private
+
+ def render_connected
+ action = "/settings/google/disconnect"
+ p { "Connected. Lists can sync from your YouTube playlists." }
+ form(method: "post", action:, data_confirm: "Disconnect Google? YouTube lists will stop syncing.") do
+ input(type: "hidden", name: "_csrf", value: @csrf.call(action))
+ button(type: "submit", data_variant: "danger", data_size: "sm") { "Disconnect" }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/rank_king/views/sources/text.rb b/lib/rank_king/views/sources/text.rb
index a19df91..3bb6b96 100644
--- a/lib/rank_king/views/sources/text.rb
+++ b/lib/rank_king/views/sources/text.rb
@@ -5,6 +5,17 @@ require "phlex"
module RankKing
module Views
module Sources
+ # The Text source's fields on the new-list form.
+ class TextFields < Phlex::HTML
+ def view_template
+ label(class: "field") do
+ plain "Items"
+ textarea(name: "items", rows: 6, required: true)
+ small { "One per line, at least two." }
+ end
+ end
+ end
+
# The Text source's sync dialog: every item's name, editable in place,
# and a box for new ones. It fills a <dialog> as Gloss lays one out:
# header, body, footer, so the form is the body and the footer's
diff --git a/lib/rank_king/views/sources/youtube_playlist.rb b/lib/rank_king/views/sources/youtube_playlist.rb
new file mode 100644
index 0000000..e8a0783
--- /dev/null
+++ b/lib/rank_king/views/sources/youtube_playlist.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+module RankKing
+ module Views
+ module Sources
+ # The YouTube playlist source's fields on the new-list form: the
+ # playlist to follow, once the user has connected Google.
+ class YouTubePlaylistFields < Phlex::HTML
+ def initialize(connected:, configured:)
+ @connected = connected
+ @configured = configured
+ end
+
+ def view_template
+ if @connected
+ label(class: "field") do
+ plain "Playlist"
+ input(type: "text", name: "playlist", required: true,
+ placeholder: "https://www.youtube.com/playlist?list=…")
+ small { "Its URL or ID. Private playlists work too." }
+ end
+ elsif @configured
+ p do
+ plain "Connect Google in "
+ a(href: "/settings") { "your settings" }
+ plain " to make a list from a YouTube playlist."
+ end
+ else
+ p { "This server has no Google client set up, so YouTube lists cannot be made yet." }
+ end
+ end
+ end
+
+ # The YouTube playlist source's sync dialog: there is nothing to
+ # fill in, so it says what syncing does and shows it working.
+ class YouTubePlaylist < Phlex::HTML
+ def initialize(list:, csrf:)
+ @list = list
+ @csrf = csrf
+ end
+
+ def view_template
+ action = "/lists/#{@list.id}/sync"
+
+ header { "Sync from YouTube" }
+ form(id: "sync-form", method: "post", action:, data_busy: "Syncing…") do
+ input(type: "hidden", name: "_csrf", value: @csrf.call(action))
+ p { "Fetches the playlist again. New videos are added, and videos that left it are hidden." }
+ if (synced_at = @list.synced_at)
+ small { "Last synced #{synced_at.strftime("%b %-d, %Y at %-I:%M %p")}." }
+ end
+ end
+ footer do
+ button(type: "button", commandfor: "sync-dialog", command: "close") { "Cancel" }
+ button(type: "submit", form: "sync-form", data_variant: "primary") { "Sync" }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index 82cdc63..909b507 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -1,10 +1,13 @@
# frozen_string_literal: true
require "roda"
+require "securerandom"
require_relative "models"
require_relative "proxy_auth"
+require_relative "google"
require_relative "views/home"
+require_relative "views/settings"
require_relative "views/list/new"
require_relative "views/list/show"
require_relative "views/list/compare"
@@ -16,10 +19,15 @@ module RankKing
plugin :static, %w[ /css /js /vendor ]
plugin :sessions, secret: CONFIG.session_secret
plugin :route_csrf, csrf_failure: :empty_403, check_request_methods: %w[POST]
- plugin :error_handler, classes: [ProxyAuth::MissingLogin] do |e|
- response.status = 401
+ plugin :error_handler, classes: [ProxyAuth::MissingLogin, Google::Error] do |e|
response["Content-Type"] = "text/plain"
- "Unauthorized: #{e.message}.\n"
+ if e.is_a?(Google::Error)
+ response.status = 502
+ "Google: #{e.message}\n"
+ else
+ response.status = 401
+ "Unauthorized: #{e.message}.\n"
+ end
end
route do |r|
@@ -31,6 +39,46 @@ module RankKing
Views::Home.new(lists: lists).call
end
+ r.on "settings" do
+ r.get true do
+ Views::Settings.new(user:, google_configured: Google.configured?, csrf: method(:csrf_token)).call
+ end
+
+ # Connecting Google is the OAuth dance: off to Google with a state
+ # only this session knows, and back with a code for a refresh token.
+ r.on "google" do
+ redirect_uri = "#{r.base_url}/settings/google/callback"
+
+ r.get "connect" do
+ r.halt 404 unless Google.configured?
+
+ state = SecureRandom.hex(16)
+ session["google_state"] = state
+ r.redirect Google.authorize_url(redirect_uri:, state:)
+ end
+
+ r.get "callback" do
+ state = session.delete("google_state").to_s
+ r.halt 403 if state.empty? || !Rack::Utils.secure_compare(state, r.params["state"].to_s)
+ # The user said no on Google's side.
+ r.redirect "/settings" if r.params["error"]
+
+ refresh_token = Google.refresh_token(r.params["code"].to_s, redirect_uri:)
+ if (account = user.google_account)
+ account.update(refresh_token:)
+ else
+ GoogleAccount.create(user_id: user.id, refresh_token:)
+ end
+ r.redirect "/settings"
+ end
+
+ r.post "disconnect" do
+ user.google_account&.destroy
+ r.redirect "/settings"
+ end
+ end
+ end
+
r.on "lists" do
r.is do
r.post do
@@ -40,28 +88,27 @@ module RankKing
source_kind = r.params["source"] || "Text"
r.halt 422 unless Sources::KINDS.include?(source_kind)
- items_text = r.params["items"].to_s.strip
- r.halt 422 if items_text.empty?
-
- item_names = items_text.lines.map(&:strip).reject(&:empty?)
- r.halt 422 if item_names.size < 2
-
axis_names = names_param(r.params["axes"])
+ # The source fills the list from its own fields, or turns it
+ # down and nothing is made.
list = DB.transaction do
l = List.create(name: name, user_id: user.id, source_kind: source_kind)
- item_names.each { |n| Item.create(list_id: l.id, name: n) }
+ raise Sequel::Rollback unless Sources.fetch(source_kind).create(l, r.params)
+
axis_names.each { |n| Axis.create(list_id: l.id, name: n) }
l
end
+ r.halt 422 unless list
r.redirect "/lists/#{list.id}"
end
end
r.get "new" do
- kinds = Sources::KINDS.to_h do |kind|
- [kind, Sources.fetch(kind).title] #: [String, String]
+ kinds = Sources::KINDS.map do |kind|
+ source = Sources.fetch(kind)
+ [kind, source.title, source.fields(user)] #: [String, String, Sources::_Component]
end
Views::ListViews::New.new(sources: kinds, csrf: method(:csrf_token)).call
end
diff --git a/public/css/app.css b/public/css/app.css
index 3899300..ed2fbfa 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -40,6 +40,9 @@ section {
gap: var(--gl-space-2xs);
}
+/* A section's own action keeps its button's width. */
+section > :is(a.btn, form) { align-self: flex-start; }
+
/* Actions at the foot of a form: one on the right, or two pushed apart. */
form footer {
display: flex;
@@ -153,3 +156,27 @@ main > header [popovertarget] { anchor-name: --rk-list-actions; }
}
[data-matchup] + .btn { align-self: center; }
+
+/* -------------------- */
+/* Busy */
+/* -------------------- */
+
+/* A button whose form is still working (see data-busy in app.js) spins
+ where an icon would go. Gloss has no spinner, and a busy button never
+ carries an icon, so its ::before is free. */
+button[aria-busy="true"]::before {
+ content: "";
+ display: inline-block;
+ width: 1em;
+ height: 1em;
+ margin-inline-end: var(--gl-space-2xs);
+ vertical-align: -0.125em;
+ border: 2px solid currentColor;
+ border-inline-end-color: transparent;
+ border-radius: 50%;
+ animation: rk-spin 0.8s linear infinite;
+}
+
+@keyframes rk-spin {
+ to { rotate: 1turn; }
+}
diff --git a/public/js/app.js b/public/js/app.js
index f2ea4f5..66edbce 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -40,4 +40,34 @@
var message = event.target.dataset.confirm;
if (message && !window.confirm(message)) event.preventDefault();
});
+
+ // A slow form shows it is working: <form data-busy="Syncing…"> relabels
+ // the button that sent it and marks it busy until the next page loads.
+ document.addEventListener("submit", function (event) {
+ var label = event.target.dataset.busy;
+ var button = event.submitter;
+ if (!label || !button || event.defaultPrevented) return;
+
+ button.textContent = label;
+ button.setAttribute("aria-busy", "true");
+ setTimeout(function () { button.disabled = true; });
+ });
+
+ // The new-list form shows only the chosen source's fields. Disabling
+ // the others keeps their required fields from blocking the submit.
+ function showSource(select) {
+ select.form.querySelectorAll("fieldset[data-source]").forEach(function (fieldset) {
+ var chosen = fieldset.dataset.source === select.value;
+ fieldset.disabled = !chosen;
+ fieldset.hidden = !chosen;
+ });
+ }
+
+ document.addEventListener("change", function (event) {
+ if (event.target.matches("select[name=source]")) showSource(event.target);
+ });
+
+ document.addEventListener("DOMContentLoaded", function () {
+ document.querySelectorAll("select[name=source]").forEach(showSource);
+ });
})();
diff --git a/sig/gems/rack.rbs b/sig/gems/rack.rbs
new file mode 100644
index 0000000..e3ed899
--- /dev/null
+++ b/sig/gems/rack.rbs
@@ -0,0 +1,7 @@
+# Rack ships no signatures. web.rb calls only this of it directly.
+module Rack
+ module Utils
+ # Compares two strings in time that does not depend on where they differ.
+ def self.secure_compare: (String a, String b) -> bool
+ end
+end
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index 742fbb9..2f35e90 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -22,9 +22,15 @@ class Roda
def response: () -> RodaResponse
+ # The sessions plugin's: the signed cookie session.
+ def session: () -> Hash[String, untyped]
+
class RodaRequest
def env: () -> Hash[String, untyped]
+ # Scheme, host and port, as the client reached the app.
+ def base_url: () -> String
+
def is: (*untyped matchers) { (*untyped) -> untyped } -> void
def on: (*untyped matchers) { (*untyped) -> untyped } -> void
def root: () { () -> untyped } -> void
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index 5fff59b..a6ff83e 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -17,7 +17,11 @@ module Sequel
class Database
def []: (Symbol table) -> Dataset[row]
- def transaction: [T] () { () -> T } -> T
+ # Raising Rollback inside the block undoes it and returns nil.
+ def transaction: [T] () { () -> T } -> T?
+ end
+
+ class Rollback < StandardError
end
# Generic over what a row comes back as: a hash from a plain dataset,
@@ -29,6 +33,7 @@ module Sequel
# Rows keyed by one column's value.
def to_hash: (Symbol key) -> Hash[untyped, T]
+ | (Symbol key, Symbol value) -> Hash[untyped, untyped]
def order: (*untyped columns) -> Dataset[T]
@@ -36,6 +41,16 @@ module Sequel
def first: () -> T?
+ # One column of the first row.
+ def get: (Symbol column) -> untyped
+
+ def insert: (**untyped values) -> Integer
+
+ # Inserts, replacing the row whose key conflicts.
+ def replace: (**untyped values) -> Integer
+
+ def join: (Symbol table, untyped conditions) -> Dataset[T]
+
def select: (*Symbol columns) -> Dataset[row]
def each: () { (T) -> void } -> void
@@ -50,6 +65,8 @@ module Sequel
def self.many_to_one: (Symbol name, **untyped options) -> void
+ def self.one_to_one: (Symbol name, **untyped options) -> void
+
def self.create: (**untyped values) -> instance
def self.all: () -> Array[instance]
diff --git a/sig/rank_king/config.rbs b/sig/rank_king/config.rbs
index f359a38..1625670 100644
--- a/sig/rank_king/config.rbs
+++ b/sig/rank_king/config.rbs
@@ -5,7 +5,9 @@ module RankKing
class Config
attr_reader database_url: String
attr_reader session_secret: String
+ attr_reader google_client_id: String?
+ attr_reader google_client_secret: String?
- def self.new: (database_url: String, session_secret: String) -> instance
+ def self.new: (database_url: String, session_secret: String, google_client_id: String?, google_client_secret: String?) -> instance
end
end
diff --git a/sig/rank_king/google.rbs b/sig/rank_king/google.rbs
new file mode 100644
index 0000000..b41a984
--- /dev/null
+++ b/sig/rank_king/google.rbs
@@ -0,0 +1,9 @@
+# The transport's writer is defined on the singleton class, which the
+# inline parser cannot read; tests are what call it.
+module RankKing
+ module Google
+ self.@transport: (^(Symbol, String, Hash[Symbol, String], String?) -> Hash[String, untyped])?
+
+ def self.transport=: (^(Symbol, String, Hash[Symbol, String], String?) -> Hash[String, untyped]) -> void
+ end
+end
diff --git a/sig/rank_king/models.rbs b/sig/rank_king/models.rbs
index 76e948a..074ea63 100644
--- a/sig/rank_king/models.rbs
+++ b/sig/rank_king/models.rbs
@@ -6,6 +6,12 @@ module RankKing
def login: () -> String
def lists: () -> Array[List]
def lists_dataset: () -> Sequel::Dataset[List]
+ def google_account: () -> GoogleAccount?
+ end
+
+ class GoogleAccount < Sequel::Model
+ def user_id: () -> Integer
+ def refresh_token: () -> String
end
class List < Sequel::Model
diff --git a/sig/rank_king/sources.rbs b/sig/rank_king/sources.rbs
index bc7f3af..ae193ec 100644
--- a/sig/rank_king/sources.rbs
+++ b/sig/rank_king/sources.rbs
@@ -40,6 +40,13 @@ module RankKing
# What the kind is called where a list's source is chosen or shown.
def title: () -> String
+
+ # The kind's own fields on the new-list form.
+ def fields: (User user) -> _Component
+
+ # Sets a new list up from what those fields posted: false, and the
+ # list is not made, when they are not enough to go on.
+ def create: (List list, Hash[String, untyped] params) -> bool
end
# A view component, as a source's form is.
diff --git a/sig/rank_king/views.rbs b/sig/rank_king/views.rbs
index ce245a1..168667a 100644
--- a/sig/rank_king/views.rbs
+++ b/sig/rank_king/views.rbs
@@ -8,16 +8,36 @@ module RankKing
def call: () -> String
end
+ class Settings
+ def initialize: (user: User, google_configured: bool, csrf: Method) -> void
+ def call: () -> String
+ end
+
module Sources
class Text
def initialize: (list: List, csrf: Method) -> void
def call: () -> String
end
+
+ class TextFields
+ def initialize: () -> void
+ def call: () -> String
+ end
+
+ class YouTubePlaylist
+ def initialize: (list: List, csrf: Method) -> void
+ def call: () -> String
+ end
+
+ class YouTubePlaylistFields
+ def initialize: (connected: bool, configured: bool) -> void
+ def call: () -> String
+ end
end
module ListViews
class New
- def initialize: (sources: Hash[String, String], csrf: Method) -> void
+ def initialize: (sources: Array[[String, String, ::RankKing::Sources::_Component]], csrf: Method) -> void
def call: () -> String
end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 29efc4c..9458bfb 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,3 +1,7 @@
# frozen_string_literal: true
ENV["DATABASE_URL"] = ":memory:"
+# A Google client, so the YouTube routes are on; no request leaves the
+# tests, which swap RankKing::Google.transport for canned answers.
+ENV["GOOGLE_CLIENT_ID"] = "client-id"
+ENV["GOOGLE_CLIENT_SECRET"] = "client-secret"
diff --git a/test/test_youtube.rb b/test/test_youtube.rb
new file mode 100644
index 0000000..3e0252d
--- /dev/null
+++ b/test/test_youtube.rb
@@ -0,0 +1,289 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+
+require "minitest/autorun"
+require "rack/test"
+require "uri"
+
+require_relative "../lib/rank_king/web"
+
+class TestYouTube < Minitest::Test
+ include Rack::Test::Methods
+
+ def app = RankKing::Web.app
+
+ LOGIN = "alpha@example.com"
+ PLAYLIST = "PLabc123"
+
+ # Google, as far as rank-king talks to it. The playlist pages two videos
+ # at a time, so fetching it has to follow nextPageToken.
+ class FakeGoogle
+ attr_reader :calls
+ attr_accessor :videos, :error
+
+ def initialize
+ @calls = []
+ @videos = []
+ end
+
+ def call(method, url, params, access_token)
+ @calls << [method, url, params, access_token]
+ raise RankKing::Google::Error, error if error
+
+ case url
+ when RankKing::Google::TOKEN_URL
+ if params[:grant_type] == "authorization_code"
+ { "refresh_token" => "refresh-#{params[:code]}" }
+ else
+ { "access_token" => "access-#{params[:refresh_token]}" }
+ end
+ when RankKing::Google::PLAYLIST_ITEMS_URL
+ page = params[:pageToken].to_i
+ body = { "items" => videos[page * 2, 2].to_a.map { |id, title| item(id, title) } }
+ body["nextPageToken"] = (page + 1).to_s if videos.size > (page + 1) * 2
+ body
+ end
+ end
+
+ private
+
+ # A nil title stands for a deleted video, which has no owner.
+ def item(id, title)
+ snippet = { "title" => title || "Deleted video", "resourceId" => { "videoId" => id } }
+ snippet["videoOwnerChannelTitle"] = "A channel" if title
+ { "snippet" => snippet }
+ end
+ end
+
+ def setup
+ header "Remote-User", LOGIN
+ RankKing::DB[:results].delete
+ RankKing::DB[:axes].delete
+ RankKing::DB[:items].delete
+ RankKing::DB[:lists].delete
+ RankKing::DB[:users].delete
+
+ @google = FakeGoogle.new
+ RankKing::Google.transport = @google.method(:call)
+ end
+
+ def teardown
+ RankKing::Google.transport = nil
+ end
+
+ def test_settings_offers_to_connect_google
+ get "/settings"
+ assert last_response.ok?
+ assert_includes last_response.body, %(href="/settings/google/connect")
+ end
+
+ def test_settings_says_when_google_is_not_set_up
+ unconfigured do
+ get "/settings"
+ refute_includes last_response.body, "/settings/google/connect"
+ assert_includes last_response.body, "GOOGLE_CLIENT_ID"
+
+ get "/settings/google/connect"
+ assert_equal 404, last_response.status
+ end
+ end
+
+ def test_connecting_google_keeps_a_refresh_token
+ get "/settings/google/connect"
+ assert last_response.redirect?
+ location = URI(last_response.location)
+ query = URI.decode_www_form(location.query).to_h
+ assert_equal "accounts.google.com", location.host
+ assert_equal "client-id", query["client_id"]
+ assert_equal "http://example.org/settings/google/callback", query["redirect_uri"]
+ assert_equal RankKing::Google::SCOPE, query["scope"]
+ assert_equal "offline", query["access_type"]
+
+ get "/settings/google/callback", { state: query["state"], code: "the-code" }
+ assert_equal "/settings", URI(last_response.location).path
+ assert_equal "refresh-the-code", user.google_account.refresh_token
+
+ get "/settings"
+ assert_includes last_response.body, %(action="/settings/google/disconnect")
+ end
+
+ def test_callback_needs_the_state_it_was_sent_with
+ get "/settings/google/connect"
+ get "/settings/google/callback", { state: "forged", code: "the-code" }
+
+ assert_equal 403, last_response.status
+ assert_nil user.google_account
+ end
+
+ def test_callback_without_a_connect_is_refused
+ get "/settings/google/callback", { state: "", code: "the-code" }
+ assert_equal 403, last_response.status
+ end
+
+ def test_declining_on_google_connects_nothing
+ get "/settings/google/connect"
+ state = URI.decode_www_form(URI(last_response.location).query).to_h["state"]
+ get "/settings/google/callback", { state:, error: "access_denied" }
+
+ assert_equal "/settings", URI(last_response.location).path
+ assert_nil user.google_account
+ assert_empty @google.calls
+ end
+
+ def test_disconnecting_google
+ connect_google
+ csrf_post "/settings/google/disconnect", {}, from: "/settings"
+
+ assert last_response.redirect?
+ assert_nil user.google_account
+ end
+
+ def test_new_list_asks_to_connect_google_first
+ get "/lists/new"
+ assert_includes last_response.body, %(<option value="YouTubePlaylist">YouTube playlist</option>)
+ assert_includes last_response.body, %(<a href="/settings">your settings</a>)
+ refute_includes last_response.body, %(name="playlist")
+ end
+
+ def test_new_list_takes_a_playlist_once_connected
+ connect_google
+ get "/lists/new"
+ assert_includes last_response.body, %(name="playlist")
+ end
+
+ def test_create_a_list_from_a_playlist
+ connect_google
+ @google.videos = [["v1", "First"], ["v2", nil], ["v3", "Third"], ["v4", "Fourth"], ["v1", "First again"]]
+
+ create_list(playlist: "https://www.youtube.com/playlist?list=#{PLAYLIST}&si=x")
+ assert last_response.redirect?
+
+ list = RankKing::List.first
+ assert_equal "YouTubePlaylist", list.source_kind
+ refute_nil list.synced_at
+ assert_equal %w[First Third Fourth], names(list)
+ assert_equal PLAYLIST, RankKing::DB[:youtube_playlist_sources].where(list_id: list.id).get(:playlist_id)
+ assert_equal %w[v1 v3 v4], RankKing::DB[:youtube_playlist_items].order(:item_id).map(:video_id)
+
+ tokens = @google.calls.select { |_, url, _, _| url == RankKing::Google::TOKEN_URL }
+ assert_equal "refresh-the-code", tokens.last[2][:refresh_token]
+ pages = @google.calls.select { |_, url, _, _| url == RankKing::Google::PLAYLIST_ITEMS_URL }
+ assert_equal 3, pages.size
+ assert_equal ["access-refresh-the-code"], pages.map(&:last).uniq
+ end
+
+ def test_create_needs_google
+ create_list(playlist: PLAYLIST)
+ assert_equal 422, last_response.status
+ assert_equal 0, RankKing::List.count
+ end
+
+ def test_create_needs_a_playlist
+ connect_google
+ create_list(playlist: "not a playlist")
+ assert_equal 422, last_response.status
+ assert_equal 0, RankKing::List.count
+ end
+
+ def test_sync_adds_archives_and_restores_by_video
+ connect_google
+ @google.videos = [["v1", "First"], ["v2", "Second"]]
+ create_list(playlist: PLAYLIST)
+ list = RankKing::List.first
+ first, second = list.items_dataset.order(:id).all
+
+ @google.videos = [["v1", "First, renamed"], ["v3", "Third"]]
+ sync(list)
+ assert last_response.redirect?
+ assert_equal ["First, renamed", "Third"], names(list)
+ refute_nil second.reload.archived_at
+ assert_equal "First, renamed", first.reload.name
+
+ @google.videos = [["v2", "Second"]]
+ sync(list)
+ assert_equal %w[Second], names(list)
+ assert_nil second.reload.archived_at
+ end
+
+ def test_sync_dialog_shows_progress
+ connect_google
+ @google.videos = [["v1", "First"], ["v2", "Second"]]
+ create_list(playlist: PLAYLIST)
+
+ get "/lists/#{RankKing::List.first.id}"
+ assert_includes last_response.body, "Sync from YouTube"
+ assert_includes last_response.body, %(data-busy="Syncing…")
+ end
+
+ def test_google_errors_are_reported
+ connect_google
+ @google.videos = [["v1", "First"], ["v2", "Second"]]
+ create_list(playlist: PLAYLIST)
+ list = RankKing::List.first
+
+ @google.error = "Token has been expired or revoked."
+ sync(list)
+ assert_equal 502, last_response.status
+ assert_includes last_response.body, "Token has been expired or revoked."
+ assert_equal %w[First Second], names(list)
+ end
+
+ def test_deleting_a_youtube_list
+ connect_google
+ @google.videos = [["v1", "First"], ["v2", "Second"]]
+ create_list(playlist: PLAYLIST)
+ list = RankKing::List.first
+
+ csrf_post "/lists/#{list.id}/delete", {}, from: "/lists/#{list.id}/settings"
+ assert last_response.redirect?
+ assert_equal 0, RankKing::DB[:youtube_playlist_sources].count
+ assert_equal 0, RankKing::DB[:youtube_playlist_items].count
+ end
+
+ def test_playlist_id
+ playlist_id = RankKing::Sources::YouTubePlaylist.method(:playlist_id)
+ assert_equal PLAYLIST, playlist_id.(" #{PLAYLIST} ")
+ assert_equal PLAYLIST, playlist_id.("https://www.youtube.com/playlist?list=#{PLAYLIST}")
+ assert_equal PLAYLIST, playlist_id.("https://www.youtube.com/watch?v=abc&list=#{PLAYLIST}&index=2")
+ assert_nil playlist_id.("https://www.youtube.com/watch?v=abc")
+ end
+
+ private
+
+ def user = RankKing::User.first(login: LOGIN)
+
+ # As if the server had no Google client.
+ def unconfigured
+ google = RankKing::Google.singleton_class
+ google.alias_method :__configured?, :configured?
+ google.define_method(:configured?) { false }
+ yield
+ ensure
+ google.alias_method :configured?, :__configured?
+ google.remove_method :__configured?
+ end
+
+ def connect_google
+ get "/settings/google/connect"
+ state = URI.decode_www_form(URI(last_response.location).query).to_h["state"]
+ get "/settings/google/callback", { state:, code: "the-code" }
+ @google.calls.clear
+ end
+
+ def names(list) = list.items_dataset.order(:id).map(:name)
+
+ def create_list(playlist:)
+ csrf_post "/lists", { name: "Videos", source: "YouTubePlaylist", playlist: }, from: "/lists/new"
+ end
+
+ def sync(list)
+ csrf_post "/lists/#{list.id}/sync", {}, from: "/lists/#{list.id}"
+ end
+
+ def csrf_post(path, params, from:)
+ get from
+ token = last_response.body[/action="#{Regexp.escape(path)}".*?name="_csrf" value="([^"]+)"/m, 1]
+ post path, params.merge("_csrf" => token)
+ end
+end