diff --git a/backend/app/controllers/admin/youtube_controller.rb b/backend/app/controllers/admin/youtube_controller.rb new file mode 100644 index 0000000..c76e14e --- /dev/null +++ b/backend/app/controllers/admin/youtube_controller.rb @@ -0,0 +1,13 @@ +module Admin + class YoutubeController < Admin::BaseController + def platform + if ENV["YOUTUBE_CLIENT_ID"].blank? + redirect_to admin_root_path, alert: "Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env" + return + end + + state = Youtube::OauthState.for_platform(admin_id: current_admin_account.id) + redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true + end + end +end diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index 39d1c27..22c445f 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -70,6 +70,7 @@ module Api def team_json(team, detail: false) ent = team.entitlements + yt = Youtube::TeamStatus.new(team) data = { id: team.id, name: team.name, @@ -79,7 +80,10 @@ module Api secondary_color: team.effective_secondary_color, club_id: team.club_id, club_name: team.club.name, - youtube_connected: team.youtube_credential.present?, + youtube_connected: yt.connected?, + youtube_selectable: yt.selectable?, + youtube_channel_title: yt.channel_title, + youtube_uses_platform_channel: yt.uses_platform_channel?, plan_slug: ent.plan.slug, plan_name: ent.plan.name, premium_active: ent.premium_active?, diff --git a/backend/app/controllers/api/v1/youtube_controller.rb b/backend/app/controllers/api/v1/youtube_controller.rb index 507787a..848c68f 100644 --- a/backend/app/controllers/api/v1/youtube_controller.rb +++ b/backend/app/controllers/api/v1/youtube_controller.rb @@ -1,25 +1,70 @@ module Api module V1 class YoutubeController < BaseController + include ActionController::Cookies + + skip_before_action :authenticate_request!, only: :callback + def authorize team = current_user.manageable_teams.find(params[:team_id]) team.entitlements.assert_can_connect_youtube! - url = Youtube::OauthUrl.build(team_id: team.id, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI")) + state = Youtube::OauthState.for_team(team_id: team.id, user_id: current_user.id) + url = Youtube::OauthUrl.build(state: state, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI")) render json: { authorization_url: url } end def callback - team = current_user.manageable_teams.find(params[:state]) + return redirect_to_oauth_error("Codice OAuth mancante") if params[:code].blank? + + ctx = Youtube::OauthState.verify!(params[:state]) tokens = Youtube::OauthExchange.call(params[:code]) + + if ctx[:kind] == :platform + unless platform_oauth_authorized?(ctx[:admin_id]) + return redirect_to "#{admin_base_url}/login", alert: "Sessione admin richiesta", allow_other_host: true + end + + return render_platform_token(tokens) + end + + user = User.find(ctx[:user_id]) + team = user.manageable_teams.find(ctx[:team_id]) cred = team.youtube_credential || team.build_youtube_credential cred.update!( access_token: tokens[:access_token], - refresh_token: tokens[:refresh_token], + refresh_token: tokens[:refresh_token] || cred.refresh_token, expires_at: Time.current + tokens[:expires_in].seconds, channel_id: tokens[:channel_id], channel_title: tokens[:channel_title] ) - redirect_to "/admin/teams/#{team.id}?youtube=connected" + redirect_to "#{public_base_url}/teams/#{team.id}/dettagli?youtube=connected", allow_other_host: true + rescue ArgumentError, Youtube::BroadcastService::Error => e + redirect_to_oauth_error(e.message) + end + + private + + def public_base_url + MatchLiveTv.app_public_url.chomp("/") + end + + def redirect_to_oauth_error(message) + redirect_to "#{public_base_url}/prezzi?youtube_error=#{ERB::Util.url_encode(message)}", + allow_other_host: true + end + + def render_platform_token(tokens) + @refresh_token = tokens[:refresh_token] + @channel_title = tokens[:channel_title] + render "admin/youtube/platform_token", layout: "admin" + end + + def platform_oauth_authorized?(admin_id) + session[:admin_account_id].present? && session[:admin_account_id].to_s == admin_id.to_s + end + + def admin_base_url + "#{public_base_url}/admin" end end end diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb index 4969703..3fcaae1 100644 --- a/backend/app/controllers/public/teams_controller.rb +++ b/backend/app/controllers/public/teams_controller.rb @@ -7,6 +7,7 @@ module Public before_action :set_team, only: %i[ details dashboard roster edit update invite create_invitation assign_self_staff clear_self_staff remove_member destroy_invitation + youtube_connect youtube_disconnect ] def new @@ -114,6 +115,27 @@ module Public redirect_to public_team_details_path(@team), notice: "Invito annullato" end + def youtube_connect + require_club_owner_for_team!(@team) + @team.entitlements.assert_can_connect_youtube! + + if ENV["YOUTUBE_CLIENT_ID"].blank? + redirect_to public_team_details_path(@team), alert: "YouTube OAuth non configurato sul server" + return + end + + state = Youtube::OauthState.for_team(team_id: @team.id, user_id: current_user.id) + redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true + rescue Teams::EntitlementError => e + redirect_to public_team_details_path(@team), alert: e.message + end + + def youtube_disconnect + require_club_owner_for_team!(@team) + @team.youtube_credential&.destroy! + redirect_to public_team_details_path(@team), notice: "Canale YouTube scollegato" + end + private def load_team_details! diff --git a/backend/app/models/plan.rb b/backend/app/models/plan.rb index 74b2045..ebd4f00 100644 --- a/backend/app/models/plan.rb +++ b/backend/app/models/plan.rb @@ -25,9 +25,11 @@ class Plan < ApplicationRecord end def allows_platform?(platform) - return false if platform.to_s == "youtube" && !youtube_enabled? + platform = platform.to_s + return false if platform == "youtube" && !youtube_enabled? + return true if platform == "youtube" && youtube_mode == "matchlivetv_light" - Array(features["platforms"]).map(&:to_s).include?(platform.to_s) + Array(features["platforms"]).map(&:to_s).include?(platform) end def feature(key) diff --git a/backend/app/models/youtube/platform_credential.rb b/backend/app/models/youtube/platform_credential.rb new file mode 100644 index 0000000..af0b123 --- /dev/null +++ b/backend/app/models/youtube/platform_credential.rb @@ -0,0 +1,23 @@ +module Youtube + # Credenziali del canale YouTube Match Live TV (Premium Light), da ENV. + class PlatformCredential + attr_accessor :access_token, :expires_at + + def refresh_token + ENV["YOUTUBE_PLATFORM_REFRESH_TOKEN"].presence + end + + def expired? + expires_at.present? && expires_at < Time.current + end + + def update!(attrs) + self.access_token = attrs[:access_token] if attrs.key?(:access_token) + self.expires_at = attrs[:expires_at] if attrs.key?(:expires_at) + end + + def self.configured? + ENV["YOUTUBE_PLATFORM_REFRESH_TOKEN"].present? + end + end +end diff --git a/backend/app/services/youtube/broadcast_service.rb b/backend/app/services/youtube/broadcast_service.rb index c82a88d..de8d9d5 100644 --- a/backend/app/services/youtube/broadcast_service.rb +++ b/backend/app/services/youtube/broadcast_service.rb @@ -4,7 +4,8 @@ module Youtube def initialize(team) @team = team - @credential = team.youtube_credential + @resolver = CredentialResolver.new(team) + @credential = @resolver.resolve end def create_broadcast!(title:, privacy_status: "unlisted", scheduled_at: nil) diff --git a/backend/app/services/youtube/channel_info.rb b/backend/app/services/youtube/channel_info.rb new file mode 100644 index 0000000..89eab6a --- /dev/null +++ b/backend/app/services/youtube/channel_info.rb @@ -0,0 +1,18 @@ +module Youtube + class ChannelInfo + def self.fetch(access_token:) + service = Google::Apis::YoutubeV3::YouTubeService.new + service.authorization = access_token + resp = service.list_channels("snippet", mine: true, max_results: 1) + item = resp.items&.first + return { channel_id: nil, channel_title: nil } unless item + + { + channel_id: item.id, + channel_title: item.snippet&.title + } + rescue Google::Apis::Error + { channel_id: nil, channel_title: nil } + end + end +end diff --git a/backend/app/services/youtube/credential_resolver.rb b/backend/app/services/youtube/credential_resolver.rb new file mode 100644 index 0000000..ed6d4e8 --- /dev/null +++ b/backend/app/services/youtube/credential_resolver.rb @@ -0,0 +1,24 @@ +module Youtube + class CredentialResolver + def self.for_team(team) + new(team).resolve + end + + def initialize(team) + @team = team + @mode = team.entitlements.plan.youtube_mode + end + + def resolve + if @mode == "matchlivetv_light" && PlatformCredential.configured? + PlatformCredential.new + else + @team.youtube_credential + end + end + + def uses_platform_channel? + @mode == "matchlivetv_light" && PlatformCredential.configured? + end + end +end diff --git a/backend/app/services/youtube/oauth_exchange.rb b/backend/app/services/youtube/oauth_exchange.rb index 7a47726..9aab1eb 100644 --- a/backend/app/services/youtube/oauth_exchange.rb +++ b/backend/app/services/youtube/oauth_exchange.rb @@ -13,12 +13,13 @@ module Youtube data = JSON.parse(response.body) raise BroadcastService::Error, data["error_description"] if data["error"] + info = ChannelInfo.fetch(access_token: data["access_token"]) { access_token: data["access_token"], refresh_token: data["refresh_token"], expires_in: data["expires_in"].to_i, - channel_id: nil, - channel_title: nil + channel_id: info[:channel_id], + channel_title: info[:channel_title] } end end diff --git a/backend/app/services/youtube/oauth_state.rb b/backend/app/services/youtube/oauth_state.rb new file mode 100644 index 0000000..d0af87f --- /dev/null +++ b/backend/app/services/youtube/oauth_state.rb @@ -0,0 +1,39 @@ +module Youtube + class OauthState + PURPOSE = :youtube_oauth_connect + + class << self + def for_team(team_id:, user_id:) + verifier.generate([team_id.to_s, user_id.to_s], expires_in: 1.hour) + end + + def for_platform(admin_id:) + verifier.generate(["platform", admin_id.to_s], expires_in: 1.hour) + end + + def verify!(token) + payload = verifier.verify(token) + case payload + when Array + if payload.first == "platform" + { kind: :platform, admin_id: payload[1] } + elsif payload.size == 2 + { kind: :team, team_id: payload[0], user_id: payload[1] } + else + raise ArgumentError, "stato OAuth non valido" + end + else + raise ArgumentError, "stato OAuth non valido" + end + rescue ActiveSupport::MessageVerifier::InvalidSignature + raise ArgumentError, "stato OAuth scaduto o non valido" + end + + private + + def verifier + Rails.application.message_verifier(PURPOSE) + end + end + end +end diff --git a/backend/app/services/youtube/oauth_url.rb b/backend/app/services/youtube/oauth_url.rb index 36b3738..7672d09 100644 --- a/backend/app/services/youtube/oauth_url.rb +++ b/backend/app/services/youtube/oauth_url.rb @@ -6,7 +6,7 @@ module Youtube "https://www.googleapis.com/auth/youtube.force-ssl" ].join(" ").freeze - def self.build(team_id:, redirect_uri:) + def self.build(state:, redirect_uri: ENV["YOUTUBE_REDIRECT_URI"]) params = { client_id: ENV.fetch("YOUTUBE_CLIENT_ID", "not_configured"), redirect_uri: redirect_uri, @@ -14,7 +14,7 @@ module Youtube scope: SCOPES, access_type: "offline", prompt: "consent", - state: team_id + state: state } "#{AUTH_URL}?#{URI.encode_www_form(params)}" end diff --git a/backend/app/services/youtube/team_status.rb b/backend/app/services/youtube/team_status.rb new file mode 100644 index 0000000..e83c03b --- /dev/null +++ b/backend/app/services/youtube/team_status.rb @@ -0,0 +1,44 @@ +module Youtube + class TeamStatus + PLATFORM_LABEL = "Match Live TV".freeze + + def initialize(team) + @team = team + @ent = team.entitlements + @mode = @ent.plan.youtube_mode + end + + def selectable? + return false unless @ent.youtube_enabled? + + case @mode + when "matchlivetv_light" + PlatformCredential.configured? + when "team" + @team.youtube_credential.present? + else + false + end + end + + def connected? + selectable? + end + + def uses_platform_channel? + @mode == "matchlivetv_light" && PlatformCredential.configured? + end + + def channel_title + if uses_platform_channel? + PLATFORM_LABEL + else + @team.youtube_credential&.channel_title + end + end + + def needs_team_oauth? + @mode == "team" && @ent.premium_full? + end + end +end diff --git a/backend/app/views/admin/teams/show.html.erb b/backend/app/views/admin/teams/show.html.erb index 99e2b9b..e1a76fa 100644 --- a/backend/app/views/admin/teams/show.html.erb +++ b/backend/app/views/admin/teams/show.html.erb @@ -3,7 +3,12 @@ <% if @team.club %>

Società: <%= @team.club.name %> · <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @team.club.id) %>

<% end %> -

YouTube: <%= @team.youtube_credential ? "Connesso" : link_to("Connetti", "/api/v1/teams/#{@team.id}/youtube/authorize") %>

+<% yt = Youtube::TeamStatus.new(@team) %> +

YouTube: <%= yt.channel_title || "—" %> · <%= yt.selectable? ? "pronto" : "non pronto" %>

+<% if @team.entitlements.plan.youtube_mode == "team" && @team.youtube_credential.blank? %> +

<%= link_to "Collega (da account titolare sul sito)", public_team_details_path(@team) %>

+<% end %> +

<%= link_to "Token canale Match Live TV (Light)", admin_youtube_platform_path %>

Partite