diff --git a/backend/app/controllers/public/replay_controller.rb b/backend/app/controllers/public/replay_controller.rb index e58dea9..c417660 100644 --- a/backend/app/controllers/public/replay_controller.rb +++ b/backend/app/controllers/public/replay_controller.rb @@ -52,6 +52,15 @@ module Public private def serve_video(disposition:) + expires_in = disposition == "attachment" ? MatchLiveTv.replay_download_url_ttl_seconds : MatchLiveTv.replay_presigned_url_ttl_seconds + redirect_url = Recordings::PublicMediaUrl.new( + key: @recording.storage_key, + expires_in: expires_in, + disposition: disposition, + filename: disposition == "attachment" ? download_filename : nil + ).call + return redirect_to redirect_url, allow_other_host: true if redirect_url.present? + Recordings::ServeFromStorage.new( key: @recording.storage_key, content_type: "video/mp4", @@ -66,6 +75,9 @@ module Public key = @recording.thumbnail_storage_key return head :not_found if key.blank? + redirect_url = Recordings::PublicMediaUrl.new(key: key, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds).call + return redirect_to redirect_url, allow_other_host: true if redirect_url.present? + Recordings::ServeFromStorage.new( key: key, content_type: "image/jpeg", diff --git a/backend/app/services/recordings/public_media_url.rb b/backend/app/services/recordings/public_media_url.rb new file mode 100644 index 0000000..52ad61a --- /dev/null +++ b/backend/app/services/recordings/public_media_url.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Recordings + # URL presigned S3 riscritto sul path pubblico /media/ (edge → Garage). + # Rails fa solo auth; il browser scarica/streama senza occupare Puma. + class PublicMediaUrl + def initialize(key:, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds, disposition: "inline", filename: nil) + @key = key + @expires_in = expires_in + @disposition = disposition + @filename = filename + end + + def call + return nil if @key.blank? + return nil unless MatchLiveTv.replay_media_redirect_enabled? + + raw = Recordings::Storage.new.presigned_get_url( + key: @key, + expires_in: @expires_in, + response_content_disposition: content_disposition_param + ) + return nil if raw.blank? + + rewrite_to_public(raw) + end + + private + + def rewrite_to_public(raw) + uri = URI.parse(raw) + base = MatchLiveTv.replay_media_public_base_url.chomp("/") + query = uri.query.presence + path = uri.path.start_with?("/") ? uri.path : "/#{uri.path}" + query ? "#{base}#{path}?#{query}" : "#{base}#{path}" + end + + def content_disposition_param + return nil unless @disposition == "attachment" && @filename.present? + + %(attachment; filename="#{@filename.gsub('"', '\\"')}") + end + end +end diff --git a/backend/app/services/recordings/storage.rb b/backend/app/services/recordings/storage.rb index 49c6ecf..a2721f5 100644 --- a/backend/app/services/recordings/storage.rb +++ b/backend/app/services/recordings/storage.rb @@ -31,8 +31,12 @@ module Recordings dest end - def presigned_get_url(key:, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds) - @backend.presigned_get_url(key: key, expires_in: expires_in) + def presigned_get_url(key:, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds, response_content_disposition: nil) + @backend.presigned_get_url( + key: key, + expires_in: expires_in, + response_content_disposition: response_content_disposition + ) end def local_path_for_key(key) @@ -61,7 +65,7 @@ module Recordings true end - def presigned_get_url(key:, expires_in:) + def presigned_get_url(key:, expires_in:, response_content_disposition: nil) nil end @@ -92,14 +96,15 @@ module Recordings raise Error, e.message end - def presigned_get_url(key:, expires_in:) + def presigned_get_url(key:, expires_in:, response_content_disposition: nil) signer = Aws::S3::Presigner.new(client: client) - signer.presigned_url( - :get_object, + params = { bucket: MatchLiveTv.replay_storage_bucket, key: key, expires_in: expires_in - ) + } + params[:response_content_disposition] = response_content_disposition if response_content_disposition.present? + signer.presigned_url(:get_object, **params) end def local_path_for_key(_key) diff --git a/backend/config/initializers/match_live_tv.rb b/backend/config/initializers/match_live_tv.rb index ab60d88..1431762 100644 --- a/backend/config/initializers/match_live_tv.rb +++ b/backend/config/initializers/match_live_tv.rb @@ -141,6 +141,16 @@ module MatchLiveTv ENV.fetch("REPLAY_DOWNLOAD_URL_TTL_SECONDS", "900").to_i end + def replay_media_public_base_url + ENV.fetch("REPLAY_MEDIA_PUBLIC_BASE_URL") { "#{app_public_url.chomp('/')}/media" } + end + + def replay_media_redirect_enabled? + return false if replay_storage_local? + + ENV.fetch("REPLAY_MEDIA_REDIRECT", "true") == "true" + end + def google_analytics_measurement_id ENV["GOOGLE_ANALYTICS_MEASUREMENT_ID"].presence end diff --git a/backend/spec/services/recordings/public_media_url_spec.rb b/backend/spec/services/recordings/public_media_url_spec.rb new file mode 100644 index 0000000..d775c72 --- /dev/null +++ b/backend/spec/services/recordings/public_media_url_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Recordings::PublicMediaUrl do + before do + allow(MatchLiveTv).to receive(:replay_storage_local?).and_return(false) + allow(MatchLiveTv).to receive(:replay_media_redirect_enabled?).and_return(true) + allow(MatchLiveTv).to receive(:replay_media_public_base_url).and_return("https://www.matchlivetv.it/media") + allow(MatchLiveTv).to receive(:replay_presigned_url_ttl_seconds).and_return(7200) + end + + it "riscrive l'URL presigned Garage sul path pubblico /media/" do + storage = instance_double(Recordings::Storage) + allow(Recordings::Storage).to receive(:new).and_return(storage) + allow(storage).to receive(:presigned_get_url).and_return( + "http://garage:3900/matchlivetv-replays/teams/abc/video.mp4?X-Amz-Signature=abc" + ) + + url = described_class.new(key: "teams/abc/video.mp4").call + + expect(url).to eq( + "https://www.matchlivetv.it/media/matchlivetv-replays/teams/abc/video.mp4?X-Amz-Signature=abc" + ) + end + + it "restituisce nil se il redirect è disabilitato" do + allow(MatchLiveTv).to receive(:replay_media_redirect_enabled?).and_return(false) + + expect(described_class.new(key: "teams/abc/video.mp4").call).to be_nil + end + + it "passa content-disposition per i download" do + storage = instance_double(Recordings::Storage) + allow(Recordings::Storage).to receive(:new).and_return(storage) + allow(storage).to receive(:presigned_get_url).and_return("http://garage:3900/bucket/key.mp4?sig=1") + + described_class.new( + key: "key.mp4", + disposition: "attachment", + filename: "partita.mp4" + ).call + + expect(storage).to have_received(:presigned_get_url).with( + hash_including( + key: "key.mp4", + response_content_disposition: %(attachment; filename="partita.mp4") + ) + ) + end +end diff --git a/infra/.env.production.example b/infra/.env.production.example index d4373e4..5755ba4 100644 --- a/infra/.env.production.example +++ b/infra/.env.production.example @@ -66,6 +66,12 @@ REPLAY_STORAGE_REGION=garage REPLAY_STORAGE_ACCESS_KEY_ID= REPLAY_STORAGE_SECRET_ACCESS_KEY= REPLAY_STORAGE_FORCE_PATH_STYLE=true +# Streaming replay: Rails fa auth, edge serve /media/ → Garage (non passa da Puma) +REPLAY_MEDIA_PUBLIC_BASE_URL=https://www.matchlivetv.it/media +REPLAY_MEDIA_REDIRECT=true + +# Puma: 5 thread (no secondo worker senza più RAM) +RAILS_MAX_THREADS=5 # Ops monitoring — notifiche push (ntfy self-hosted) e health check # 1) Avvia servizio: docker compose up -d ntfy diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index a4c29de..f079a57 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -55,6 +55,7 @@ services: command: bundle exec rails server -b 0.0.0.0 -p 3000 -e production environment: RAILS_ENV: production + RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5} DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/matchlivetv REDIS_URL: redis://redis:6379/0 MEDIAMTX_API_URL: http://mediamtx:9997 @@ -157,6 +158,7 @@ services: command: bundle exec sidekiq -C config/sidekiq.yml -e production environment: RAILS_ENV: production + RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5} DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/matchlivetv REDIS_URL: redis://redis:6379/0 MEDIAMTX_API_URL: http://mediamtx:9997 diff --git a/infra/nginx-edge/nginx.conf b/infra/nginx-edge/nginx.conf index a37407f..17722b8 100644 --- a/infra/nginx-edge/nginx.conf +++ b/infra/nginx-edge/nginx.conf @@ -22,6 +22,28 @@ http { set $rails_backend rails:3000; + # Replay MP4/thumbnail: solo lettura da Garage (URL presigned da Rails). + location /media/ { + limit_except GET HEAD { + deny all; + } + + rewrite ^/media/(.*)$ /$1 break; + proxy_pass http://garage:3900; + proxy_http_version 1.1; + proxy_buffering off; + proxy_request_buffering off; + proxy_connect_timeout 5s; + proxy_read_timeout 3600s; + # Presigned S3 firmati sull'endpoint interno garage:3900 + proxy_set_header Host garage:3900; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Range $http_range; + proxy_set_header If-Range $http_if_range; + } + location = /edge-health { access_log off; add_header Content-Type text/plain; diff --git a/infra/scripts/cron/scan_ops_logs.sh b/infra/scripts/cron/scan_ops_logs.sh index b04752a..8f8ed4b 100644 --- a/infra/scripts/cron/scan_ops_logs.sh +++ b/infra/scripts/cron/scan_ops_logs.sh @@ -32,4 +32,5 @@ if [[ ! -s "$TMP" ]]; then exit 0 fi -"${ROOT}/scripts/cron/run_rails_task.sh" ops:ingest_logs["$TMP"] +# Il file temp è sull'host: passa il contenuto via stdin al container Rails. +"${ROOT}/scripts/cron/run_rails_task.sh" ops:ingest_logs < "$TMP" diff --git a/infra/scripts/install_production_cron.sh b/infra/scripts/install_production_cron.sh index c5cc1ee..02b269f 100755 --- a/infra/scripts/install_production_cron.sh +++ b/infra/scripts/install_production_cron.sh @@ -29,7 +29,7 @@ CRON_BLOCK="${MARKER} 30 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1 0 3 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1 0 8 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1 -*/10 * * * * mkdir -p ${LOG_DIR} && ${SCAN_LOGS} >> ${OPS_LOG_FILE} 2>&1 +*/10 * * * * mkdir -p ${LOG_DIR} && /bin/bash ${SCAN_LOGS} >> ${OPS_LOG_FILE} 2>&1 " existing="$(crontab -l 2>/dev/null || true)" diff --git a/native/android/.gitignore b/native/android/.gitignore index f975f94..f2cfc63 100644 --- a/native/android/.gitignore +++ b/native/android/.gitignore @@ -6,3 +6,5 @@ /captures/ /app/build/ .DS_Store +keystore.properties +keystore/ diff --git a/native/android/app/build.gradle.kts b/native/android/app/build.gradle.kts index 1519c6f..184a6df 100644 --- a/native/android/app/build.gradle.kts +++ b/native/android/app/build.gradle.kts @@ -1,9 +1,17 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") } +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(keystorePropertiesFile.inputStream()) +} + android { namespace = "com.matchlivetv.match_live_tv" compileSdk = 35 @@ -21,6 +29,17 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = rootProject.file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { isMinifyEnabled = true @@ -28,7 +47,11 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) - signingConfig = signingConfigs.getByName("debug") + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } diff --git a/scripts/backup_android_keystore.sh b/scripts/backup_android_keystore.sh new file mode 100755 index 0000000..a250e33 --- /dev/null +++ b/scripts/backup_android_keystore.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Backup locale del keystore Android (Play Console upload key). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ANDROID="${ROOT}/native/android" +KEYSTORE_DIR="${ANDROID}/keystore" +PROPS="${ANDROID}/keystore.properties" +DEST="${ANDROID_KEYSTORE_BACKUP_DIR:-${HOME}/.matchlivetv/android-keystore-backup}" +STAMP="$(date +%Y%m%d-%H%M%S)" + +if [ ! -f "${PROPS}" ] || [ ! -d "${KEYSTORE_DIR}" ]; then + echo "ERRORE: keystore non trovato in ${ANDROID}" + echo "Genera prima il keystore di release (vedi native/android/)." + exit 1 +fi + +mkdir -p "${DEST}/${STAMP}" +cp -a "${PROPS}" "${DEST}/${STAMP}/" +cp -a "${KEYSTORE_DIR}/." "${DEST}/${STAMP}/keystore/" +chmod -R u=rwX,go= "${DEST}/${STAMP}" + +echo "Backup keystore Android: ${DEST}/${STAMP}" +echo "Conserva anche una copia off-site (password manager / cloud cifrato)." diff --git a/scripts/deploy/release_production.sh b/scripts/deploy/release_production.sh index 01b1e3c..0a5469b 100755 --- a/scripts/deploy/release_production.sh +++ b/scripts/deploy/release_production.sh @@ -70,7 +70,8 @@ else fi echo "==> Proxy edge + deploy Rails (sposta :3000 su edge)" -compose up -d --no-deps --build edge rails +compose up -d --no-deps --force-recreate edge +compose up -d --no-deps --build rails wait_for_rails echo "==> Deploy Sidekiq"