Replace Spotify search with a validated music link box

The Spotify Web API integration stopped working, leaving /spotify unable to
suggest songs at all. Replace the artist/album/track search flow with a single
box where the user pastes a link to the track on a music service.

Links are checked against a hardcoded allow-list of legal streaming and store
domains so links to ripped audio cannot be submitted. The check lives in the
service layer, not just the page: any authenticated user can POST to
/api/ganjoor/song directly and bypass the UI. Hosts are matched against the
full host or a dot-prefixed suffix, so look-alikes such as
open.spotify.com.evil.com are rejected; https is required, and userinfo and
non-default ports are refused. Accepted URLs are canonicalised - https,
lowercased host, tracking parameters stripped - so the same track always yields
the same stored URL and duplicate detection actually works.

Links are stored under one new type, PoemMusicTrackType.MusicUrl, with the
platform derived from the host at render time. That needs no migration or
backfill, and supporting another service later needs no new enum value. The
duplicate check no longer keys on TrackType, which closes a gap where the same
URL could be resubmitted as a different type. Several links per poem remain
allowed; only an identical URL for the same poem is refused.

The Spotify search page and its OAuth plumbing are kept and simply redirect to
/musiclink while the existing SpotifyWorking flag is false, so the old flow can
be restored if that API ever works again.

Track URLs are no longer written through Html.Raw into href attributes. They
previously came from the Spotify API; now that they are user supplied, encoding
them prevents stored XSS.

Also fixes two latent bugs in SuggestSong that this flow would have hit: a null
dereference when TrackUrl is empty, and a singer lookup that matched any singer
with an empty Url.
This commit is contained in:
Ehsan Mohandesi 2026-09-13 22:16:18 -05:00
parent 7f76c5c9c1
commit cb7b1b03a3
15 changed files with 860 additions and 41 deletions

View File

@ -59,19 +59,19 @@ else
@if (!string.IsNullOrEmpty(suggestedSong.ArtistName))
{
<p>
خواننده: <a href="@Html.Raw(suggestedSong.ArtistUrl)" target="_blank">@suggestedSong.ArtistName</a>
خواننده: <a href="@suggestedSong.ArtistUrl" target="_blank">@suggestedSong.ArtistName</a>
</p>
}
@if (!string.IsNullOrEmpty(suggestedSong.AlbumName))
{
<p>
آلبوم: <a href="@Html.Raw(suggestedSong.AlbumUrl)" target="_blank">@suggestedSong.AlbumName</a>
آلبوم: <a href="@suggestedSong.AlbumUrl" target="_blank">@suggestedSong.AlbumName</a>
</p>
}
<p>
آهنگ: <a href="@Html.Raw(suggestedSong.TrackUrl)" target="_blank">@suggestedSong.TrackName</a>
آهنگ: <a href="@suggestedSong.TrackUrl" target="_blank">@suggestedSong.TrackName</a>
</p>
@if (!string.IsNullOrEmpty(suggestedSong.Description))

View File

@ -59,6 +59,7 @@ else
<div>
@{
var platform = RMuseum.Utils.MusicUrlValidator.Detect(Model.PoemMusicTrackViewModel.TrackUrl);
switch (Model.PoemMusicTrackViewModel.TrackType)
{
case RMuseum.Models.Ganjoor.PoemMusicTrackType.Spotify:
@ -67,6 +68,20 @@ else
case RMuseum.Models.Ganjoor.PoemMusicTrackType.Golha:
<img src="/image/golha.png" alt="گلها" height="32" />
break;
case RMuseum.Models.Ganjoor.PoemMusicTrackType.MusicUrl:
@if (platform == null)
{
<span class="up-badge up-badge--danger">نشانی خارج از فهرست سرویس‌های مجاز</span>
}
else
{
@if (!string.IsNullOrEmpty(platform.LargeIconUrl))
{
<img src="@platform.LargeIconUrl" alt="@platform.Name" height="32" />
}
<span class="up-badge">@platform.Name</span>
}
break;
default:
<img src="/image/beeptunes.png" alt="بیپ‌تونز" height="32" />
break;
@ -79,7 +94,7 @@ else
<input asp-for="PoemMusicTrackViewModel.ArtistName" />
</div>
<div class="up-field up-field--ltr">
<label><a target="_blank" href="@Html.Raw(Model.PoemMusicTrackViewModel.ArtistUrl)">نشانی خواننده</a></label>
<label><a target="_blank" rel="noopener nofollow" href="@Model.PoemMusicTrackViewModel.ArtistUrl">نشانی خواننده</a></label>
<input asp-for="PoemMusicTrackViewModel.ArtistUrl" />
</div>
@ -88,7 +103,7 @@ else
<input asp-for="PoemMusicTrackViewModel.AlbumName" />
</div>
<div class="up-field up-field--ltr">
<label><a target="_blank" href="@Html.Raw(Model.PoemMusicTrackViewModel.AlbumUrl)">نشانی آلبوم</a></label>
<label><a target="_blank" rel="noopener nofollow" href="@Model.PoemMusicTrackViewModel.AlbumUrl">نشانی آلبوم</a></label>
<input asp-for="PoemMusicTrackViewModel.AlbumUrl" />
</div>
@ -97,7 +112,7 @@ else
<input asp-for="PoemMusicTrackViewModel.TrackName" />
</div>
<div class="up-field up-field--ltr">
<label><a target="_blank" href="@Html.Raw(Model.PoemMusicTrackViewModel.TrackUrl)">نشانی آهنگ</a></label>
<label><a target="_blank" rel="noopener nofollow" href="@Model.PoemMusicTrackViewModel.TrackUrl">نشانی آهنگ</a></label>
<input asp-for="PoemMusicTrackViewModel.TrackUrl" />
</div>

View File

@ -794,7 +794,7 @@
{
<div class="poemtabcontent">
<p>
<a href="@Html.Raw($"/golha?p={Model.GanjoorPage.Id}")" onclick="wpopen(this.href); return false" class="pagebutton comments-link">
<a href="@Html.Raw($"/musiclink?p={Model.GanjoorPage.Id}")" onclick="wpopen(this.href); return false" class="pagebutton comments-link">
معرفی ترانه‌هایی که در متن آنها از این شعر استفاده شده است<i class="notranslate info-buttons music_note"></i>
</a>
</p>
@ -805,11 +805,12 @@
<div class="poemtabcontent" id="songs">
@foreach (var song in Model.GanjoorPage.Poem.Songs)
{
var songPlatform = RMuseum.Utils.MusicUrlValidator.Detect(song.TrackUrl);
<div class="related-song">
<i class="notranslate info-buttons queue_music"></i><a target="_blank" href="@Html.Raw(song.TrackUrl)">@Html.Raw($"{song}")</a>
@if (song.TrackType == RMuseum.Models.Ganjoor.PoemMusicTrackType.Spotify)
<i class="notranslate info-buttons queue_music"></i><a target="_blank" rel="noopener nofollow" href="@song.TrackUrl">@song.ToString()</a>
@if (songPlatform != null && !string.IsNullOrEmpty(songPlatform.IconUrl))
{
<a target="_blank" href="@Html.Raw(song.TrackUrl)"><img class="spotify-track-icon" src="/image/sp16.png" alt="اسپاتیفای" height="16"></a>
<a target="_blank" rel="noopener nofollow" href="@song.TrackUrl"><img class="spotify-track-icon" src="@songPlatform.IconUrl" alt="@songPlatform.Name" title="@songPlatform.Name" height="16"></a>
}
@if (Model.CanEdit)
{
@ -818,7 +819,7 @@
</div>
}
<p>
<a href="@Html.Raw($"/golha?p={Model.GanjoorPage.Id}")" onclick="wpopen(this.href); return false" class="pagebutton comments-link">
<a href="@Html.Raw($"/musiclink?p={Model.GanjoorPage.Id}")" onclick="wpopen(this.href); return false" class="pagebutton comments-link">
معرفی ترانه‌های دیگر<i class="notranslate info-buttons music_note"></i>
</a>
</p>

View File

@ -20,8 +20,8 @@
<script src="/js/beeptunes.js"></script>
<script>
$(function () {
$('#spotify').on('click', function() {
window.location.href = "@Html.Raw($"/spotify/?p={Model.PoemId}")";
$('#musiclink').on('click', function() {
window.location.href = "@Html.Raw($"/musiclink/?p={Model.PoemId}")";
});
$('#golha').on('click', function () {
@ -96,8 +96,8 @@
<label for="golha"><a href="@Html.Raw($"/golha/?p={Model.PoemId}")"><img src="/image/golha.png" alt="گلها" height="32" /></a></label>
<input type="radio" id="beeptunes" name="beeptunes" value="بیپ‌تونز" checked>
<label for="beeptunes"><a href="@Html.Raw($"/bp/?p={Model.PoemId}")"><img src="/image/beeptunes.png" alt="بیپ‌تونز" height="32" /></a></label>
<input type="radio" id="spotify" name="spotify" value="اسپاتیفای">
<label for="spotify"><a href="@Html.Raw($"/spotify/?p={Model.PoemId}")"><img src="/image/spotify.png" alt="اسپاتیفای" height="32" /></a></label>
<input type="radio" id="musiclink" name="musiclink" value="پیوند آهنگ">
<label for="musiclink"><a href="@Html.Raw($"/musiclink/?p={Model.PoemId}")"><img src="/image/note.png" alt="پیوند آهنگ" height="32" /></a></label>
</div>
</div>
<p>
@ -117,14 +117,14 @@
@if (suggestedSong.Id == Model.InsertedSongId)
{
<text>
<a target="_blank" href="@Html.Raw(suggestedSong.ArtistUrl)" style="color:green">@suggestedSong.ArtistName</a> » <a target="_blank" href="@Html.Raw(suggestedSong.AlbumUrl)" style="color:green">@suggestedSong.AlbumName</a> » <a target="_blank" href="@Html.Raw(suggestedSong.TrackUrl)" style="color:green">@suggestedSong.TrackName</a>
<a target="_blank" href="@suggestedSong.ArtistUrl" style="color:green">@suggestedSong.ArtistName</a> » <a target="_blank" href="@suggestedSong.AlbumUrl" style="color:green">@suggestedSong.AlbumName</a> » <a target="_blank" href="@suggestedSong.TrackUrl" style="color:green">@suggestedSong.TrackName</a>
</text>
}
else
{
<text>
<a target="_blank" href="@Html.Raw(suggestedSong.ArtistUrl)">@suggestedSong.ArtistName</a> » <a target="_blank" href="@Html.Raw(suggestedSong.AlbumUrl)">@suggestedSong.AlbumName</a> » <a target="_blank" href="@Html.Raw(suggestedSong.TrackUrl)">@suggestedSong.TrackName</a>
<a target="_blank" href="@suggestedSong.ArtistUrl">@suggestedSong.ArtistName</a> » <a target="_blank" href="@suggestedSong.AlbumUrl">@suggestedSong.AlbumName</a> » <a target="_blank" href="@suggestedSong.TrackUrl">@suggestedSong.TrackName</a>
</text>
}

View File

@ -17,8 +17,8 @@
<script src="~/js/golha.js"></script>
<script>
$(function () {
$('#spotify').on('click', function() {
window.location.href = "@Html.Raw($"/spotify/?p={Model.PoemId}")";
$('#musiclink').on('click', function() {
window.location.href = "@Html.Raw($"/musiclink/?p={Model.PoemId}")";
});
$('#golha').on('click', function () {
@ -87,8 +87,8 @@
<label for="golha"><a href="@Html.Raw($"/golha/?p={Model.PoemId}")"><img src="/image/golha.png" alt="گلها" height="32" /></a></label>
<input type="radio" id="beeptunes" name="beeptunes" value="بیپ‌تونز">
<label for="beeptunes"><a href="@Html.Raw($"/bp/?p={Model.PoemId}")"><img src="/image/beeptunes.png" alt="بیپ‌تونز" height="32" /></a></label>
<input type="radio" id="spotify" name="spotify" value="اسپاتیفای">
<label for="spotify"><a href="@Html.Raw($"/spotify/?p={Model.PoemId}")"><img src="/image/spotify.png" alt="اسپاتیفای" height="32" /></a></label>
<input type="radio" id="musiclink" name="musiclink" value="پیوند آهنگ">
<label for="musiclink"><a href="@Html.Raw($"/musiclink/?p={Model.PoemId}")"><img src="/image/note.png" alt="پیوند آهنگ" height="32" /></a></label>
</div>
</div>
@ -109,14 +109,14 @@
@if (suggestedSong.Id == Model.InsertedSongId)
{
<text>
<a target="_blank" href="@Html.Raw(suggestedSong.TrackUrl)" style="color:green">@suggestedSong.AlbumName</a> » <a target="_blank" href="@Html.Raw(suggestedSong.TrackUrl)" style="color:green">@suggestedSong.TrackName</a>
<a target="_blank" href="@suggestedSong.TrackUrl" style="color:green">@suggestedSong.AlbumName</a> » <a target="_blank" href="@suggestedSong.TrackUrl" style="color:green">@suggestedSong.TrackName</a>
</text>
}
else
{
<text>
<a target="_blank" href="@Html.Raw(suggestedSong.TrackUrl)">@suggestedSong.AlbumName</a> » <a target="_blank" href="@Html.Raw(suggestedSong.TrackUrl)">@suggestedSong.TrackName</a>
<a target="_blank" href="@suggestedSong.TrackUrl">@suggestedSong.AlbumName</a> » <a target="_blank" href="@suggestedSong.TrackUrl">@suggestedSong.TrackName</a>
</text>
}

View File

@ -0,0 +1,155 @@
@page
@model GanjooRazor.Pages.MusicLinkModel
@{
Layout = null;
}
<!DOCTYPE html>
<html lang="fa-IR" dir="rtl">
<head>
<meta charset="utf-8" />
<title>پیشنهاد پیوند قطعهٔ موسیقی مرتبط با شعر</title>
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="/favicon.ico?version=1" type="image/x-icon" />
<link href="/css/p8.css?version=89" rel="stylesheet" type="text/css" />
<link href="/css/spotify.css" rel="stylesheet" type="text/css" />
<script src="/lib/jquery/dist/jquery.min.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/musiclink.js?version=1"></script>
</head>
<body>
<div id="fa">
<div class="poem">
@if (Model.ReadOnlyMode)
{
<div class="width-100per">
<div class="width-100per text-align-center">
<p class="alert-text">
امکان پیشنهاد قطعه موقتا به دلیل تغییرات سایت و یا انتقال سرور وجود ندارد. لطفاً ساعاتی دیگر مجدداً تلاش کنید.
</p>
</div>
</div>
}
else
@if (Model.PoemId == 0)
{
<div class="width-100per">
<div class="width-100per text-align-center">
<p class="alert-text">
خطا: شعری انتخاب نشده است.
</p>
</div>
</div>
}
else
if (!Model.LoggedIn)
{
<div class="width-100per">
<div class="width-100per text-align-center">
<p class="alert-text">
برای پیشنهاد آهنگهای مرتبط با اشعار لازم است ابتدا با نام کاربری خود وارد گنجور شوید.
</p>
<p class="text-align-center">
<a href=@Html.Raw($"/login/?redirect=/musiclink/?p={Model.PoemId}")>ورود به گنجور</a>
</p>
</div>
</div>
}
else
{
@if (!string.IsNullOrEmpty(Model.LastError))
{
<div class="width-100per">
<div class="width-100per text-align-center">
<p class="alert-text">
@Model.LastError
</p>
</div>
</div>
}
<div class="width-100per">
<div class="width-100per text-align-center">
<h3><strong>انتخاب منبع:</strong></h3>
</div>
<div style="width:100%;text-align:center">
<input type="radio" id="golha" name="source" value="گلها">
<label for="golha"><a href="@($"/golha/?p={Model.PoemId}")"><img src="/image/golha.png" alt="گلها" height="32" /></a></label>
<input type="radio" id="beeptunes" name="source" value="بیپ‌تونز">
<label for="beeptunes"><a href="@($"/bp/?p={Model.PoemId}")"><img src="/image/beeptunes.png" alt="بیپ‌تونز" height="32" /></a></label>
<input type="radio" id="musiclink" name="source" value="پیوند آهنگ" checked>
<label for="musiclink"><a href="@($"/musiclink/?p={Model.PoemId}")"><img src="/image/note.png" alt="پیوند آهنگ" height="32" /></a></label>
</div>
</div>
<p>
با استفاده از این صفحه می‌توانید آهنگهایی را که در متن آنها از شعر جاری استفاده شده است معرفی کنید. نشانی صفحهٔ آهنگ را از سرویس پخش یا فروش موسیقی کپی کرده و در جعبهٔ «نشانی آهنگ» بچسبانید. نام خواننده و عنوان آهنگ در صورت امکان به صورت خودکار پر می‌شود و می‌توانید آن را اصلاح کنید. پس از «پیشنهاد قطعه» آهنگ پیشنهادی در صف بررسی قرار می‌گیرد و پس از بازبینی و تأیید به صفحهٔ اصلی شعر اضافه می‌شود. از این که کمک می‌کنید که گنجور به نمایه‌ای برای موسیقی ایرانی بدل شود سپاسگزاریم.
</p>
<p>
<span style="color:red">تنها نشانی از این سرویس‌ها پذیرفته می‌شود:</span> @RMuseum.Utils.MusicUrlValidator.PlatformNames. نشانی فایلهای صوتی تکثیر شده و سایتهای دانلود غیرقانونی پذیرفته نمی‌شود.
</p>
<p>
<span style="color:red">تذکر مهم:</span> فقط مواردی تأیید می‌شود که <span style="color:red">در آهنگ متناظر همین شعر خوانده شود</span>. تعیین نام خواننده به تنهایی کارایی ندارد و لطفا جهت کاهش زمان مورد نیاز برای بررسی موارد پیشنهادی از اعلام موارد اینچنینی خودداری فرمایید. فهرست کامل آهنگهای مرتبط در <a href="https://ganjoor.net/mundex/" target="_blank">این صفحه</a> در دسترس است.
</p>
@if (Model.SuggestedSongs.Length > 0)
{
<p>موارد زیر شامل قطعه‌هایی هستند که پیشتر توسط دوستان دیگر پیشنهاد شده و در صف بررسی قرار دارند:</p>
@foreach (var suggestedSong in Model.SuggestedSongs)
{
<p style="@(suggestedSong.Id == Model.InsertedSongId ? "color:green" : "")">
@if (!string.IsNullOrEmpty(suggestedSong.ArtistName))
{
<text>@suggestedSong.ArtistName » </text>
}
@if (!string.IsNullOrEmpty(suggestedSong.AlbumName))
{
<text>@suggestedSong.AlbumName » </text>
}
<a target="_blank" rel="noopener nofollow" href="@suggestedSong.TrackUrl">@suggestedSong.TrackName</a>
</p>
}
}
@if (Model.PostSuccess)
{
<div class="width-100per">
<div class="width-100per text-align-center">
<p style="color:green">
با سپاس! پیشنهاد شما به فهرست اضافه شد.
</p>
</div>
</div>
}
}
</div>
@if (Model.PoemId != 0 && Model.LoggedIn && !Model.ReadOnlyMode)
{
<div class="content">
<form method="post">
<span class="inputlabel">نشانی آهنگ: </span>
<input type="url" dir="ltr" class="albumtrack" id="trackurl" placeholder="https://" required asp-for="PoemMusicTrackViewModel.TrackUrl" />
<div id="fetching-metadata" style="display:none"><img src="/image/loading.gif" alt="در حال دریافت مشخصات" /></div>
<br />
<span class="inputlabel">خواننده: </span>
<input type="text" class="albumtrack" placeholder="خواننده" required asp-for="PoemMusicTrackViewModel.ArtistName" />
<br />
<span class="inputlabel">آلبوم: </span>
<input type="text" class="albumtrack" placeholder="آلبوم (اختیاری)" asp-for="PoemMusicTrackViewModel.AlbumName" />
<br />
<span class="inputlabel">عنوان آهنگ: </span>
<input type="text" class="albumtrack" placeholder="عنوان آهنگ" required asp-for="PoemMusicTrackViewModel.TrackName" />
<br />
<span class="inputlabel">توضیح: </span>
<input type="text" class="albumtrack" placeholder="توضیح" asp-for="PoemMusicTrackViewModel.Description" />
<br />
<input type="submit" name="submit" id="submit" value="پیشنهاد قطعه" />
</form>
</div>
}
</div>
</body>
</html>

View File

@ -0,0 +1,243 @@
using GanjooRazor.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using RMuseum.Models.Ganjoor;
using RMuseum.Models.Ganjoor.ViewModels;
using RMuseum.Utils;
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace GanjooRazor.Pages
{
[IgnoreAntiforgeryToken(Order = 1001)]
public class MusicLinkModel : PageModel
{
/// <summary>
/// HttpClient instance
/// </summary>
private readonly HttpClient _httpClient;
/// <summary>
/// configuration
/// </summary>
private readonly IConfiguration Configuration;
/// <summary>
/// constructor
/// </summary>
/// <param name="httpClient"></param>
/// <param name="configuration"></param>
public MusicLinkModel(HttpClient httpClient, IConfiguration configuration)
{
_httpClient = httpClient;
Configuration = configuration;
}
/// <summary>
/// is logged on
/// </summary>
public bool LoggedIn { get; set; }
/// <summary>
/// PoemId
/// </summary>
public int PoemId { get; set; }
/// <summary>
/// Last Error
/// </summary>
public string LastError { get; set; }
/// <summary>
/// Post Success
/// </summary>
public bool PostSuccess { get; set; }
/// <summary>
/// Inserted song Id
/// </summary>
public int InsertedSongId { get; set; }
/// <summary>
/// readonly mode
/// </summary>
public bool ReadOnlyMode
{
get
{
return bool.Parse(Configuration["ReadOnlyMode"]);
}
}
/// <summary>
/// suggested (unapproved) songs
/// </summary>
public PoemMusicTrackViewModel[] SuggestedSongs { get; set; }
/// <summary>
/// api model
/// </summary>
[BindProperty]
public PoemMusicTrackViewModel PoemMusicTrackViewModel { get; set; }
/// <summary>
/// every pending suggestion of the poem is listed, whatever source it came from
/// </summary>
private async Task _GetSuggestedSongs()
{
var response = await _httpClient.GetAsync($"{APIRoot.Url}/api/ganjoor/poem/{PoemId}/songs/?approved=false&trackType={(int)PoemMusicTrackType.All}");
if (response.StatusCode == HttpStatusCode.OK)
{
SuggestedSongs = JsonConvert.DeserializeObject<PoemMusicTrackViewModel[]>(await response.Content.ReadAsStringAsync());
}
else
{
SuggestedSongs = new PoemMusicTrackViewModel[] { };
}
}
public async Task OnGetAsync()
{
PostSuccess = false;
LastError = "";
InsertedSongId = 0;
LoggedIn = !string.IsNullOrEmpty(Request.Cookies["Token"]);
if (!string.IsNullOrEmpty(Request.Query["p"]))
{
PoemId = int.Parse(Request.Query["p"]);
}
else
{
PoemId = 0;
}
await _GetSuggestedSongs();
}
public async Task<IActionResult> OnPostAsync()
{
PostSuccess = false;
LastError = "";
LoggedIn = !string.IsNullOrEmpty(Request.Cookies["Token"]);
PoemId = PoemMusicTrackViewModel.PoemId = int.Parse(Request.Query["p"]);
PoemMusicTrackViewModel.TrackType = PoemMusicTrackType.MusicUrl;
InsertedSongId = 0;
if (!MusicUrlValidator.TryNormalize(PoemMusicTrackViewModel.TrackUrl, out string normalizedUrl, out _, out string urlError))
{
LastError = urlError;
await _GetSuggestedSongs();
return Page();
}
PoemMusicTrackViewModel.TrackUrl = normalizedUrl;
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
{
var stringContent = new StringContent(JsonConvert.SerializeObject(PoemMusicTrackViewModel), Encoding.UTF8, "application/json");
var methodUrl = $"{APIRoot.Url}/api/ganjoor/song";
var response = await secureClient.PostAsync(methodUrl, stringContent);
if (!response.IsSuccessStatusCode)
{
LastError = await _ReadErrorAsync(response);
}
else
{
InsertedSongId = JsonConvert.DeserializeObject<PoemMusicTrackViewModel>(await response.Content.ReadAsStringAsync()).Id;
PostSuccess = true;
}
}
else
{
LastError = "لطفاً از گنجور خارج و مجددا به آن وارد شوید.";
}
}
await _GetSuggestedSongs();
return Page();
}
/// <summary>
/// the API returns its errors as a JSON string, but 401s and framework level failures
/// come back as an empty or non-JSON body
/// </summary>
/// <param name="response"></param>
/// <returns></returns>
private static async Task<string> _ReadErrorAsync(HttpResponseMessage response)
{
if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden)
return "نشست شما معتبر نیست. لطفاً از گنجور خارج و مجدداً وارد شوید.";
string body = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(body))
return $"خطا در ارتباط با گنجور ({(int)response.StatusCode}).";
try
{
return JsonConvert.DeserializeObject<string>(body) ?? body;
}
catch (JsonException)
{
return body;
}
}
/// <summary>
/// best effort track title/artist lookup through the platform's public oEmbed endpoint
/// </summary>
/// <param name="url"></param>
/// <returns></returns>
public async Task<IActionResult> OnPostFetchMetadataAsync(string url) {
if (!MusicUrlValidator.TryNormalize(url, out string normalizedUrl, out MusicPlatform platform, out string error))
{
return new BadRequestObjectResult(error);
}
if (string.IsNullOrEmpty(platform.OEmbedEndpoint))
{
return new JsonResult(new { trackName = "", artistName = "" });
}
try
{
// the pasted url is only ever handed to the platform's own fixed endpoint, and redirects
// are refused, so this cannot be steered at an arbitrary host
using var handler = new HttpClientHandler() { AllowAutoRedirect = false };
using var oEmbedClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(5), MaxResponseContentBufferSize = 128 * 1024 };
var response = await oEmbedClient.GetAsync($"{platform.OEmbedEndpoint}?url={WebUtility.UrlEncode(normalizedUrl)}&format=json");
if (!response.IsSuccessStatusCode)
{
return new JsonResult(new { trackName = "", artistName = "" });
}
var parsed = JObject.Parse(await response.Content.ReadAsStringAsync());
return new JsonResult
(
new
{
trackName = parsed.SelectToken("title")?.Value<string>() ?? "",
artistName = parsed.SelectToken("author_name")?.Value<string>() ?? ""
}
);
}
catch
{
return new JsonResult(new { trackName = "", artistName = "" });
}
}
}
}

View File

@ -116,14 +116,14 @@
@if (suggestedSong.Id == Model.InsertedSongId)
{
<text>
<a target="_blank" href="@Html.Raw(suggestedSong.ArtistUrl)" style="color:green">@suggestedSong.ArtistName</a> » <a target="_blank" href="@Html.Raw(suggestedSong.AlbumUrl)" style="color:green">@suggestedSong.AlbumName</a> » <a target="_blank" href="@Html.Raw(suggestedSong.TrackUrl)" style="color:green">@suggestedSong.TrackName</a>
<a target="_blank" href="@suggestedSong.ArtistUrl" style="color:green">@suggestedSong.ArtistName</a> » <a target="_blank" href="@suggestedSong.AlbumUrl" style="color:green">@suggestedSong.AlbumName</a> » <a target="_blank" href="@suggestedSong.TrackUrl" style="color:green">@suggestedSong.TrackName</a>
</text>
}
else
{
<text>
<a target="_blank" href="@Html.Raw(suggestedSong.ArtistUrl)">@suggestedSong.ArtistName</a> » <a target="_blank" href="@Html.Raw(suggestedSong.AlbumUrl)">@suggestedSong.AlbumName</a> » <a target="_blank" href="@Html.Raw(suggestedSong.TrackUrl)">@suggestedSong.TrackName</a>
<a target="_blank" href="@suggestedSong.ArtistUrl">@suggestedSong.ArtistName</a> » <a target="_blank" href="@suggestedSong.AlbumUrl">@suggestedSong.AlbumName</a> » <a target="_blank" href="@suggestedSong.TrackUrl">@suggestedSong.TrackName</a>
</text>
}

View File

@ -117,7 +117,7 @@ namespace GanjooRazor.Pages
}
}
public async Task OnGetAsync()
public async Task<IActionResult> OnGetAsync()
{
PostSuccess = false;
LastError = "";
@ -133,9 +133,16 @@ namespace GanjooRazor.Pages
PoemMusicTrackViewModel.PoemId = 0;
}
await _GetSuggestedSongs();
}
// the search flow stays in place for the day the Spotify API works again
if (!SpotifyWorking)
{
return Redirect($"/musiclink/?p={PoemId}");
}
await _GetSuggestedSongs();
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
PostSuccess = false;

View File

@ -87,6 +87,7 @@ namespace GanjooRazor
options.Conventions.AddPageRoute("/SongRecommendation/Bp", "/Bp");
options.Conventions.AddPageRoute("/SongRecommendation/Golha", "/Golha");
options.Conventions.AddPageRoute("/SongRecommendation/Spotify", "/Spotify");
options.Conventions.AddPageRoute("/SongRecommendation/MusicLink", "/MusicLink");
options.Conventions.AddPageRoute("/ImageRecommendation/Pin", "/Pin");

View File

@ -0,0 +1,38 @@
$(function () {
var $url = $("#trackurl");
var $spinner = $("#fetching-metadata");
var lastLookedUp = "";
function fillIfEmpty(selector, value) {
var $input = $(selector);
if (value && $input.val().trim() === "") {
$input.val(value);
}
}
function fetchMetadata() {
var url = $url.val().trim();
if (url === "" || url === lastLookedUp) {
return;
}
lastLookedUp = url;
$spinner.show();
$.ajax({
type: "POST",
url: "/musiclink?handler=FetchMetadata",
data: { url: url }
})
.done(function (data) {
fillIfEmpty("#PoemMusicTrackViewModel_TrackName", data.trackName);
fillIfEmpty("#PoemMusicTrackViewModel_ArtistName", data.artistName);
})
.always(function () {
$spinner.hide();
});
}
$url.on("blur", fetchMetadata);
$url.on("paste", function () {
window.setTimeout(fetchMetadata, 0);
});
});

View File

@ -24,6 +24,10 @@
/// <summary>
/// https://open.spotify.com/
/// </summary>
Spotify = 3
Spotify = 3,
/// <summary>
/// user supplied link to any platform accepted by RMuseum.Utils.MusicUrlValidator
/// </summary>
MusicUrl = 4
}
}

View File

@ -1,4 +1,5 @@
using System;
using System.Linq;
namespace RMuseum.Models.Ganjoor.ViewModels
{
@ -98,7 +99,12 @@ namespace RMuseum.Models.Ganjoor.ViewModels
/// <returns></returns>
public override string ToString()
{
return TrackType == PoemMusicTrackType.Golha ? $"{AlbumName} » {TrackName}" : $"{ArtistName} » {AlbumName} » {TrackName}";
// album is optional for user supplied links, so empty parts are dropped rather than
// rendered as an empty » » segment
var parts = TrackType == PoemMusicTrackType.Golha
? new[] { AlbumName, TrackName }
: new[] { ArtistName, AlbumName, TrackName };
return string.Join(" » ", parts.Where(p => !string.IsNullOrWhiteSpace(p)));
}
}
}

View File

@ -2,6 +2,7 @@
using RMuseum.Models.Ganjoor;
using RMuseum.Models.Ganjoor.ViewModels;
using RMuseum.Models.MusicCatalogue;
using RMuseum.Utils;
using RSecurityBackend.Models.Generic;
using System;
using System.Data;
@ -104,6 +105,38 @@ namespace RMuseum.Services.Implementation
}
/// <summary>
/// checks a user supplied track and rewrites its url to canonical form, returns null when it is acceptable
/// </summary>
/// <param name="song"></param>
/// <returns></returns>
private static string _PrepareTrackUrl(PoemMusicTrackViewModel song)
{
if (string.IsNullOrWhiteSpace(song.TrackUrl))
return "نشانی آهنگ خالی است.";
if (song.TrackType != PoemMusicTrackType.MusicUrl)
return null;
if (string.IsNullOrWhiteSpace(song.ArtistName))
return "نام خواننده را وارد کنید.";
if (string.IsNullOrWhiteSpace(song.TrackName))
return "عنوان آهنگ را وارد کنید.";
if (!MusicUrlValidator.TryNormalize(song.TrackUrl, out string normalizedUrl, out _, out string error))
return error;
song.TrackUrl = normalizedUrl;
song.ArtistName = song.ArtistName.Trim();
song.TrackName = song.TrackName.Trim();
song.AlbumName = string.IsNullOrWhiteSpace(song.AlbumName) ? "" : song.AlbumName.Trim();
song.ArtistUrl = "";
song.AlbumUrl = "";
return null;
}
/// <summary>
/// suggest song
/// </summary>
@ -138,7 +171,14 @@ namespace RMuseum.Services.Implementation
}
else
{
var alreadySuggestedSong = await _context.GanjoorPoemMusicTracks.AsNoTracking().Where(t => t.PoemId == song.PoemId && t.TrackType == song.TrackType && (t.TrackUrl == song.TrackUrl || t.TrackUrl == song.TrackUrl.Replace("https", "http")) && (t.Approved || (!t.Approved && !t.Rejected))).FirstOrDefaultAsync();
string validationError = _PrepareTrackUrl(song);
if (validationError != null)
{
return new RServiceResult<PoemMusicTrackViewModel>(null, validationError);
}
string insecureTrackUrl = song.TrackUrl.Replace("https://", "http://");
var alreadySuggestedSong = await _context.GanjoorPoemMusicTracks.AsNoTracking().Where(t => t.PoemId == song.PoemId && (t.TrackUrl == song.TrackUrl || t.TrackUrl == insecureTrackUrl) && (t.Approved || (!t.Approved && !t.Rejected))).FirstOrDefaultAsync();
if (alreadySuggestedSong != null)
{
return new RServiceResult<PoemMusicTrackViewModel>(null, "این آهنگ پیشتر برای این شعر پیشنهاد داده شده است.");
@ -164,7 +204,7 @@ namespace RMuseum.Services.Implementation
RejectionCause = ""
};
GanjoorSinger singer = await _context.GanjoorSingers.Where(s => s.Url == song.ArtistUrl).FirstOrDefaultAsync();
GanjoorSinger singer = string.IsNullOrEmpty(song.ArtistUrl) ? null : await _context.GanjoorSingers.Where(s => s.Url == song.ArtistUrl).FirstOrDefaultAsync();
if (singer != null)
{
sug.SingerId = singer.Id;
@ -280,6 +320,7 @@ namespace RMuseum.Services.Implementation
if (song.Approved && song.Rejected)
return new RServiceResult<PoemMusicTrackViewModel>(null, "song.Approved && song.Rejected");
// rejecting stays possible whatever the url is, otherwise a bad entry could never be cleared
if (song.Approved)
{
if (song.TrackType == PoemMusicTrackType.Golha)
@ -292,7 +333,14 @@ namespace RMuseum.Services.Implementation
}
else
{
var alreadySuggestedSong = await _context.GanjoorPoemMusicTracks.AsNoTracking().Where(t => t.PoemId == song.PoemId && t.TrackType == song.TrackType && (t.TrackUrl == song.TrackUrl || t.TrackUrl == song.TrackUrl.Replace("https", "http")) && t.Approved).FirstOrDefaultAsync();
string validationError = _PrepareTrackUrl(song);
if (validationError != null)
{
return new RServiceResult<PoemMusicTrackViewModel>(null, validationError);
}
string insecureTrackUrl = song.TrackUrl.Replace("https://", "http://");
var alreadySuggestedSong = await _context.GanjoorPoemMusicTracks.AsNoTracking().Where(t => t.Id != song.Id && t.PoemId == song.PoemId && (t.TrackUrl == song.TrackUrl || t.TrackUrl == insecureTrackUrl) && t.Approved).FirstOrDefaultAsync();
if (alreadySuggestedSong != null)
{
return new RServiceResult<PoemMusicTrackViewModel>(null, "این آهنگ پیشتر برای این شعر تأیید شده است.");
@ -324,7 +372,7 @@ namespace RMuseum.Services.Implementation
}
GanjoorSinger singer = await _context.GanjoorSingers.AsNoTracking().Where(s => s.Url == track.ArtistUrl).FirstOrDefaultAsync();
GanjoorSinger singer = string.IsNullOrEmpty(track.ArtistUrl) ? null : await _context.GanjoorSingers.AsNoTracking().Where(s => s.Url == track.ArtistUrl).FirstOrDefaultAsync();
if (singer != null)
{
track.SingerId = singer.Id;
@ -383,22 +431,26 @@ namespace RMuseum.Services.Implementation
(
string.IsNullOrEmpty(song.ArtistName)
||
string.IsNullOrEmpty(song.ArtistUrl)
||
string.IsNullOrEmpty(song.AlbumName)
||
string.IsNullOrEmpty(song.AlbumUrl)
||
string.IsNullOrEmpty(song.TrackName)
||
string.IsNullOrEmpty(song.TrackUrl)
||
song.TrackType != PoemMusicTrackType.BeepTunesOrKhosousi
(song.TrackType != PoemMusicTrackType.BeepTunesOrKhosousi && song.TrackType != PoemMusicTrackType.MusicUrl)
||
(song.TrackType == PoemMusicTrackType.BeepTunesOrKhosousi && (string.IsNullOrEmpty(song.ArtistUrl) || string.IsNullOrEmpty(song.AlbumUrl)))
)
{
return new RServiceResult<PoemMusicTrackViewModel>(null, "data validation err");
}
string trackUrlValidationError = _PrepareTrackUrl(song);
if (trackUrlValidationError != null)
{
return new RServiceResult<PoemMusicTrackViewModel>(null, trackUrlValidationError);
}
var duplicated = await _context.GanjoorPoemMusicTracks.Where(m => m.PoemId == song.PoemId && m.TrackUrl == song.TrackUrl).FirstOrDefaultAsync();
if (duplicated != null)
{
@ -421,7 +473,7 @@ namespace RMuseum.Services.Implementation
track.Rejected = false;
track.BrokenLink = song.BrokenLink;
GanjoorSinger singer = await _context.GanjoorSingers.Where(s => s.Url == track.ArtistUrl).FirstOrDefaultAsync();
GanjoorSinger singer = string.IsNullOrEmpty(track.ArtistUrl) ? null : await _context.GanjoorSingers.Where(s => s.Url == track.ArtistUrl).FirstOrDefaultAsync();
if (singer != null)
{
track.SingerId = singer.Id;
@ -492,6 +544,14 @@ namespace RMuseum.Services.Implementation
if (!song.Approved)
return new RServiceResult<PoemMusicTrackViewModel>(null, "!song.Approved ");
if (song.TrackType != PoemMusicTrackType.Golha)
{
string validationError = _PrepareTrackUrl(song);
if (validationError != null)
{
return new RServiceResult<PoemMusicTrackViewModel>(null, validationError);
}
}
var track = await _context.GanjoorPoemMusicTracks.Where(t => t.Id == song.Id).SingleOrDefaultAsync();
@ -504,7 +564,7 @@ namespace RMuseum.Services.Implementation
track.TrackUrl = song.TrackUrl;
track.BrokenLink = song.BrokenLink;
GanjoorSinger singer = await _context.GanjoorSingers.AsNoTracking().Where(s => s.Url == track.ArtistUrl).FirstOrDefaultAsync();
GanjoorSinger singer = string.IsNullOrEmpty(track.ArtistUrl) ? null : await _context.GanjoorSingers.AsNoTracking().Where(s => s.Url == track.ArtistUrl).FirstOrDefaultAsync();
if (singer != null)
{
track.SingerId = singer.Id;

View File

@ -0,0 +1,289 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace RMuseum.Utils
{
/// <summary>
/// a legal music streaming/store platform accepted for poem music track links
/// </summary>
public class MusicPlatform
{
/// <summary>
/// stable identifier
/// </summary>
public string Key { get; init; }
/// <summary>
/// display name (Persian)
/// </summary>
public string Name { get; init; }
/// <summary>
/// 16px icon path, null when no artwork is available yet
/// </summary>
public string IconUrl { get; init; }
/// <summary>
/// 32px icon path, null when no artwork is available yet
/// </summary>
public string LargeIconUrl { get; init; }
/// <summary>
/// oEmbed endpoint accepting a ?url= parameter, null when the platform has none
/// </summary>
public string OEmbedEndpoint { get; init; }
/// <summary>
/// accepted hosts, each matching itself and its subdomains
/// </summary>
public string[] Hosts { get; init; }
/// <summary>
/// accepted host pattern, used where the host varies by country
/// </summary>
public Regex HostPattern { get; init; }
/// <summary>
/// does the given lowercased host belong to this platform?
/// </summary>
/// <param name="host"></param>
/// <returns></returns>
public bool MatchesHost(string host)
{
if (HostPattern != null && HostPattern.IsMatch(host))
return true;
if (Hosts == null)
return false;
foreach (var acceptedHost in Hosts)
{
if (host == acceptedHost || host.EndsWith($".{acceptedHost}", StringComparison.Ordinal))
return true;
}
return false;
}
}
/// <summary>
/// validates user supplied music links against an allow-list of legal music platforms
/// </summary>
public static class MusicUrlValidator
{
/// <summary>
/// maximum accepted url length
/// </summary>
public const int MaxUrlLength = 1024;
/// <summary>
/// accepted platforms, ordered so that more specific hosts win (YouTube Music before YouTube)
/// </summary>
/// <remarks>
/// IconUrl/LargeIconUrl are only set for platforms whose artwork already exists under
/// GanjooRazor/wwwroot/image; drop a {Key}16.png/{Key}.png pair in image/music and fill them in.
/// </remarks>
public static readonly IReadOnlyList<MusicPlatform> Platforms = new List<MusicPlatform>()
{
new MusicPlatform()
{
Key = "spotify",
Name = "اسپاتیفای",
Hosts = new[] { "open.spotify.com" },
IconUrl = "/image/sp16.png",
LargeIconUrl = "/image/spotify.png",
OEmbedEndpoint = "https://open.spotify.com/oembed",
},
new MusicPlatform()
{
Key = "applemusic",
Name = "اپل موزیک",
Hosts = new[] { "music.apple.com", "itunes.apple.com" },
},
new MusicPlatform()
{
Key = "amazonmusic",
Name = "آمازون موزیک",
HostPattern = new Regex(@"^music\.amazon\.(com|com\.au|com\.br|com\.mx|com\.tr|co\.uk|co\.jp|ca|de|fr|it|es|nl|se|pl|in|sg|ae)$", RegexOptions.Compiled),
},
new MusicPlatform()
{
Key = "youtubemusic",
Name = "یوتیوب موزیک",
Hosts = new[] { "music.youtube.com" },
OEmbedEndpoint = "https://www.youtube.com/oembed",
},
new MusicPlatform()
{
Key = "youtube",
Name = "یوتیوب",
Hosts = new[] { "youtube.com", "youtu.be" },
OEmbedEndpoint = "https://www.youtube.com/oembed",
},
new MusicPlatform()
{
Key = "soundcloud",
Name = "ساندکلاود",
Hosts = new[] { "soundcloud.com" },
OEmbedEndpoint = "https://soundcloud.com/oembed",
},
new MusicPlatform()
{
Key = "iheart",
Name = "آی‌هارت رادیو",
Hosts = new[] { "iheart.com" },
},
new MusicPlatform()
{
Key = "radiojavan",
Name = "رادیو جوان",
Hosts = new[] { "radiojavan.com" },
},
new MusicPlatform()
{
Key = "beeptunes",
Name = "بیپ‌تونز",
Hosts = new[] { "beeptunes.com" },
LargeIconUrl = "/image/beeptunes.png",
},
new MusicPlatform()
{
Key = "khosousi",
Name = "خصوصی",
Hosts = new[] { "khosousi.com" },
},
};
/// <summary>
/// query parameters dropped during normalization so that the same track always yields the same url
/// </summary>
private static readonly string[] _trackingParameters = new[]
{
"si", "nd", "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
"feature", "pp", "context", "_branch_match_id", "_branch_referrer", "ref", "referrer"
};
/// <summary>
/// comma separated platform names, for user facing messages
/// </summary>
public static string PlatformNames
{
get
{
return string.Join("، ", Platforms.Select(p => p.Name));
}
}
/// <summary>
/// validate a user supplied music url and rewrite it to its canonical form
/// </summary>
/// <param name="url"></param>
/// <param name="normalizedUrl"></param>
/// <param name="platform"></param>
/// <param name="error">Persian error message when validation fails</param>
/// <returns></returns>
public static bool TryNormalize(string url, out string normalizedUrl, out MusicPlatform platform, out string error)
{
normalizedUrl = null;
platform = null;
error = null;
if (string.IsNullOrWhiteSpace(url))
{
error = "نشانی آهنگ خالی است.";
return false;
}
url = url.Trim();
if (url.Length > MaxUrlLength)
{
error = "نشانی آهنگ بیش از حد طولانی است.";
return false;
}
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri uri))
{
error = "نشانی آهنگ معتبر نیست.";
return false;
}
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
{
error = "نشانی آهنگ باید با https:// آغاز شود.";
return false;
}
if (!string.IsNullOrEmpty(uri.UserInfo))
{
error = "نشانی آهنگ معتبر نیست.";
return false;
}
if (!uri.IsDefaultPort)
{
error = "نشانی آهنگ معتبر نیست.";
return false;
}
string host = uri.IdnHost.ToLowerInvariant();
platform = Platforms.FirstOrDefault(p => p.MatchesHost(host));
if (platform == null)
{
error = $"تنها نشانی آهنگ از این سرویس‌ها پذیرفته می‌شود: {PlatformNames}.";
return false;
}
StringBuilder builder = new StringBuilder();
builder.Append("https://");
builder.Append(host);
builder.Append(uri.AbsolutePath);
string query = _StripTrackingParameters(uri.Query);
if (!string.IsNullOrEmpty(query))
{
builder.Append('?');
builder.Append(query);
}
normalizedUrl = builder.ToString();
return true;
}
/// <summary>
/// platform of an already stored url, null when it belongs to none of the accepted platforms
/// </summary>
/// <param name="url"></param>
/// <returns></returns>
public static MusicPlatform Detect(string url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out Uri uri))
return null;
string host = uri.IdnHost.ToLowerInvariant();
return Platforms.FirstOrDefault(p => p.MatchesHost(host));
}
private static string _StripTrackingParameters(string query)
{
if (string.IsNullOrEmpty(query))
return "";
List<string> kept = new List<string>();
foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
{
int separatorIndex = pair.IndexOf('=');
string key = separatorIndex < 0 ? pair : pair.Substring(0, separatorIndex);
if (_trackingParameters.Contains(key.ToLowerInvariant()))
continue;
kept.Add(pair);
}
return string.Join("&", kept);
}
}
}