Integrazione YouTube Live: OAuth squadra, canale piattaforma Light e UI.
Collegamento da Dettagli squadra e admin per il refresh token Match Live TV; API e app mobile allineate ai piani Light/Full. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
13
backend/app/controllers/admin/youtube_controller.rb
Normal file
13
backend/app/controllers/admin/youtube_controller.rb
Normal file
@@ -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
|
||||
@@ -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?,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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)
|
||||
|
||||
23
backend/app/models/youtube/platform_credential.rb
Normal file
23
backend/app/models/youtube/platform_credential.rb
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
18
backend/app/services/youtube/channel_info.rb
Normal file
18
backend/app/services/youtube/channel_info.rb
Normal file
@@ -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
|
||||
24
backend/app/services/youtube/credential_resolver.rb
Normal file
24
backend/app/services/youtube/credential_resolver.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
39
backend/app/services/youtube/oauth_state.rb
Normal file
39
backend/app/services/youtube/oauth_state.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
44
backend/app/services/youtube/team_status.rb
Normal file
44
backend/app/services/youtube/team_status.rb
Normal file
@@ -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
|
||||
@@ -3,7 +3,12 @@
|
||||
<% if @team.club %>
|
||||
<p>Società: <%= @team.club.name %> · <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @team.club.id) %></p>
|
||||
<% end %>
|
||||
<p>YouTube: <%= @team.youtube_credential ? "Connesso" : link_to("Connetti", "/api/v1/teams/#{@team.id}/youtube/authorize") %></p>
|
||||
<% yt = Youtube::TeamStatus.new(@team) %>
|
||||
<p>YouTube: <%= yt.channel_title || "—" %> · <%= yt.selectable? ? "pronto" : "non pronto" %></p>
|
||||
<% if @team.entitlements.plan.youtube_mode == "team" && @team.youtube_credential.blank? %>
|
||||
<p><%= link_to "Collega (da account titolare sul sito)", public_team_details_path(@team) %></p>
|
||||
<% end %>
|
||||
<p><%= link_to "Token canale Match Live TV (Light)", admin_youtube_platform_path %></p>
|
||||
|
||||
<h3>Partite</h3>
|
||||
<ul>
|
||||
|
||||
15
backend/app/views/admin/youtube/platform_token.html.erb
Normal file
15
backend/app/views/admin/youtube/platform_token.html.erb
Normal file
@@ -0,0 +1,15 @@
|
||||
<h1>Token canale Match Live TV</h1>
|
||||
|
||||
<% if @channel_title.present? %>
|
||||
<p>Canale: <strong><%= @channel_title %></strong></p>
|
||||
<% end %>
|
||||
|
||||
<% if @refresh_token.present? %>
|
||||
<p>Aggiungi in <code>/opt/matchlivetv/infra/.env</code> (o locale <code>infra/.env</code>):</p>
|
||||
<pre style="background:#111;color:#eee;padding:12px;overflow:auto">YOUTUBE_PLATFORM_REFRESH_TOKEN=<%= @refresh_token %></pre>
|
||||
<p class="muted">Copia ora: non verrà mostrato di nuovo. Poi riavvia il container Rails.</p>
|
||||
<% else %>
|
||||
<p class="muted">Google non ha restituito un refresh token. Ripeti il collegamento con <code>prompt=consent</code> (revoca l’accesso precedente in Google Account).</p>
|
||||
<% end %>
|
||||
|
||||
<p><%= link_to "← Dashboard admin", admin_root_path %></p>
|
||||
46
backend/app/views/public/teams/_youtube.html.erb
Normal file
46
backend/app/views/public/teams/_youtube.html.erb
Normal file
@@ -0,0 +1,46 @@
|
||||
<% yt = Youtube::TeamStatus.new(team) %>
|
||||
<% return unless entitlements.youtube_enabled? %>
|
||||
|
||||
<section class="card team-youtube" id="youtube">
|
||||
<h2>YouTube Live</h2>
|
||||
|
||||
<% if params[:youtube] == "connected" %>
|
||||
<p class="notice" style="color:#2e7d32;margin-bottom:12px">Canale YouTube collegato con successo.</p>
|
||||
<% end %>
|
||||
|
||||
<% if entitlements.plan.youtube_mode == "matchlivetv_light" %>
|
||||
<p>
|
||||
Con il piano <strong>Premium Light</strong> la diretta va sul canale ufficiale
|
||||
<strong>Match Live TV</strong>. Non serve collegare un canale della società.
|
||||
</p>
|
||||
<% if yt.selectable? %>
|
||||
<p class="muted">Canale pronto · seleziona «YouTube Live» nell’app mobile.</p>
|
||||
<% else %>
|
||||
<p class="muted">Canale Match Live TV in configurazione lato server. Contatta il supporto se YouTube non compare in app.</p>
|
||||
<% end %>
|
||||
<% elsif entitlements.premium_full? %>
|
||||
<% cred = team.youtube_credential %>
|
||||
<% if cred %>
|
||||
<p>
|
||||
Canale collegato:
|
||||
<strong><%= cred.channel_title.presence || cred.channel_id || "YouTube" %></strong>
|
||||
</p>
|
||||
<%= button_to "Scollega canale", public_team_youtube_disconnect_path(team),
|
||||
method: :delete,
|
||||
class: "btn btn-secondary",
|
||||
form: { data: { turbo_confirm: "Scollegare il canale YouTube? Le dirette future richiederanno un nuovo collegamento." } } %>
|
||||
<% else %>
|
||||
<p class="muted">
|
||||
Collega il canale YouTube della società per trasmettere dall’app con la piattaforma «YouTube Live».
|
||||
</p>
|
||||
<% if ENV["YOUTUBE_CLIENT_ID"].present? %>
|
||||
<%= link_to "Collega canale YouTube", public_team_youtube_connect_path(team), class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
<p class="muted">OAuth YouTube non ancora configurato sul server.</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="muted">YouTube sul canale della società richiede il piano Premium Full.</p>
|
||||
<%= link_to "Vedi piani", public_prezzi_path, class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
</section>
|
||||
@@ -92,6 +92,8 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render "public/teams/youtube", team: @team, entitlements: @entitlements %>
|
||||
|
||||
<%= render "public/teams/streaming_staff", team: @team, club: @club, can_manage: @can_manage,
|
||||
entitlements: @entitlements, owner_membership: @owner_membership,
|
||||
staff_memberships: @staff_memberships, pending_invitations: @pending_invitations,
|
||||
|
||||
Reference in New Issue
Block a user