1 // OpenTween - Client of Twitter
2 // Copyright (c) 2007-2011 kiri_feather (@kiri_feather) <kiri.feather@gmail.com>
3 // (c) 2008-2011 Moz (@syo68k)
4 // (c) 2008-2011 takeshik (@takeshik) <http://www.takeshik.org/>
5 // (c) 2010-2011 anis774 (@anis774) <http://d.hatena.ne.jp/anis774/>
6 // (c) 2010-2011 fantasticswallow (@f_swallow) <http://twitter.com/f_swallow>
7 // (c) 2011 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
8 // All rights reserved.
10 // This file is part of OpenTween.
12 // This program is free software; you can redistribute it and/or modify it
13 // under the terms of the GNU General public License as published by the Free
14 // Software Foundation; either version 3 of the License, or (at your option)
17 // This program is distributed in the hope that it will be useful, but
18 // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
19 // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General public License
22 // You should have received a copy of the GNU General public License along
23 // with this program. If not, see <http://www.gnu.org/licenses/>, or write to
24 // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
25 // Boston, MA 02110-1301, USA.
30 using System.Collections.Concurrent;
31 using System.Collections.Generic;
32 using System.Diagnostics.CodeAnalysis;
34 using System.Net.Http;
36 using System.Text.RegularExpressions;
37 using System.Threading;
38 using System.Threading.Tasks;
41 using OpenTween.Connection;
46 /// 短縮 URL サービスによる URL の展開・短縮を行うクラス
50 private static readonly Lazy<ShortUrl> _instance;
53 /// ShortUrl のインスタンスを取得します
55 public static ShortUrl Instance
59 /// 短縮 URL の展開を無効にするか否か
61 public bool DisableExpanding { get; set; }
64 /// 短縮 URL のキャッシュを定期的にクリアする回数
66 public int PurgeCount { get; set; }
68 public string BitlyAccessToken { get; set; } = "";
69 public string BitlyId { get; set; } = "";
70 public string BitlyKey { get; set; } = "";
72 private HttpClient http;
73 private readonly ConcurrentDictionary<Uri, Uri> urlCache = new ConcurrentDictionary<Uri, Uri>();
75 private static readonly Regex HtmlLinkPattern = new Regex(@"(<a href="")(.+?)("")");
77 private static readonly HashSet<string> ShortUrlHosts = new HashSet<string>
90 "feeds.feedburner.com",
123 /// HTTPS非対応の短縮URLサービス
125 private static readonly HashSet<string> InsecureDomains = new HashSet<string>
139 => _instance = new Lazy<ShortUrl>(() => new ShortUrl(), true);
141 [SuppressMessage("Microsoft.Reliability", "CA2000:DisposeObjectsBeforeLosingScope")]
143 : this(CreateDefaultHttpClient())
145 Networking.WebProxyChanged += (o, e) =>
147 var newClient = CreateDefaultHttpClient();
148 var oldClient = Interlocked.Exchange(ref this.http, newClient);
153 internal ShortUrl(HttpClient http)
155 this.DisableExpanding = false;
156 this.PurgeCount = 500;
164 public string ExpandUrl(string uri)
168 return this.ExpandUrlAsync(new Uri(uri), 10).Result.AbsoluteUri;
170 catch (UriFormatException)
177 /// 短縮 URL を非同期に展開します
179 /// <param name="uri">展開するURL</param>
180 /// <returns>URLの展開を行うタスク</returns>
181 public Task<Uri> ExpandUrlAsync(Uri uri)
182 => this.ExpandUrlAsync(uri, 10);
185 /// 短縮 URL を非同期に展開します
187 /// <param name="uri">展開するURL</param>
188 /// <param name="redirectLimit">再帰的に展開を試みる上限</param>
189 /// <returns>URLの展開を行うタスク</returns>
190 public async Task<Uri> ExpandUrlAsync(Uri uri, int redirectLimit)
192 if (this.DisableExpanding)
195 if (redirectLimit <= 0)
198 if (!uri.IsAbsoluteUri)
203 if (!ShortUrlHosts.Contains(uri.Host) && !IsIrregularShortUrl(uri))
207 if (this.urlCache.TryGetValue(uri, out expanded))
210 if (this.urlCache.Count > this.PurgeCount)
211 this.urlCache.Clear();
216 expanded = await this.GetRedirectTo(uri)
217 .ConfigureAwait(false);
219 catch (TaskCanceledException) { }
220 catch (HttpRequestException) { }
222 if (expanded == null || expanded == uri)
225 this.urlCache[uri] = expanded;
227 var recursiveExpanded = await this.ExpandUrlAsync(expanded, --redirectLimit)
228 .ConfigureAwait(false);
230 // URL1 -> URL2 -> URL3 のように再帰的に展開されたURLを URL1 -> URL3 としてキャッシュに格納する
231 if (recursiveExpanded != expanded)
232 this.urlCache[uri] = recursiveExpanded;
234 return recursiveExpanded;
236 catch (UriFormatException)
243 /// 短縮 URL を非同期に展開します
246 /// 不正なURLが渡された場合は例外を投げず uriStr をそのまま返します
248 /// <param name="uriStr">展開するURL</param>
249 /// <returns>URLの展開を行うタスク</returns>
250 public Task<string> ExpandUrlAsync(string uriStr)
251 => this.ExpandUrlAsync(uriStr, 10);
254 /// 短縮 URL を非同期に展開します
257 /// 不正なURLが渡された場合は例外を投げず uriStr をそのまま返します
259 /// <param name="uriStr">展開するURL</param>
260 /// <param name="redirectLimit">再帰的に展開を試みる上限</param>
261 /// <returns>URLの展開を行うタスク</returns>
262 public async Task<string> ExpandUrlAsync(string uriStr, int redirectLimit)
268 if (!uriStr.StartsWith("http", StringComparison.OrdinalIgnoreCase))
269 uri = new Uri("http://" + uriStr);
271 uri = new Uri(uriStr);
273 catch (UriFormatException)
278 var expandedUri = await this.ExpandUrlAsync(uri, redirectLimit)
279 .ConfigureAwait(false);
281 return expandedUri.OriginalString;
285 public string ExpandUrlHtml(string html)
286 => this.ExpandUrlHtmlAsync(html, 10).Result;
289 /// HTML内に含まれるリンクのURLを非同期に展開する
291 /// <param name="html">処理対象のHTML</param>
292 /// <returns>URLの展開を行うタスク</returns>
293 public Task<string> ExpandUrlHtmlAsync(string html)
294 => this.ExpandUrlHtmlAsync(html, 10);
297 /// HTML内に含まれるリンクのURLを非同期に展開する
299 /// <param name="html">処理対象のHTML</param>
300 /// <param name="redirectLimit">再帰的に展開を試みる上限</param>
301 /// <returns>URLの展開を行うタスク</returns>
302 public Task<string> ExpandUrlHtmlAsync(string html, int redirectLimit)
304 if (this.DisableExpanding)
305 return Task.FromResult(html);
307 return HtmlLinkPattern.ReplaceAsync(html, async m =>
308 m.Groups[1].Value + await this.ExpandUrlAsync(m.Groups[2].Value, redirectLimit).ConfigureAwait(false) + m.Groups[3].Value);
312 /// 指定された短縮URLサービスを使用してURLを短縮します
314 /// <param name="shortenerType">使用する短縮URLサービス</param>
315 /// <param name="srcUri">短縮するURL</param>
316 /// <returns>短縮されたURL</returns>
317 public async Task<Uri> ShortenUrlAsync(MyCommon.UrlConverter shortenerType, Uri srcUri)
319 // 既に短縮されている状態のURLであれば短縮しない
320 if (ShortUrlHosts.Contains(srcUri.Host))
325 switch (shortenerType)
327 case MyCommon.UrlConverter.TinyUrl:
328 return await this.ShortenByTinyUrlAsync(srcUri)
329 .ConfigureAwait(false);
330 case MyCommon.UrlConverter.Isgd:
331 return await this.ShortenByIsgdAsync(srcUri)
332 .ConfigureAwait(false);
333 case MyCommon.UrlConverter.Bitly:
334 return await this.ShortenByBitlyAsync(srcUri, "bit.ly")
335 .ConfigureAwait(false);
336 case MyCommon.UrlConverter.Jmp:
337 return await this.ShortenByBitlyAsync(srcUri, "j.mp")
338 .ConfigureAwait(false);
339 case MyCommon.UrlConverter.Uxnu:
340 return await this.ShortenByUxnuAsync(srcUri)
341 .ConfigureAwait(false);
343 throw new ArgumentException("Unknown shortener.", nameof(shortenerType));
346 catch (OperationCanceledException)
348 // 短縮 URL の API がタイムアウトした場合
353 private async Task<Uri> ShortenByTinyUrlAsync(Uri srcUri)
355 // 明らかに長くなると推測できる場合は短縮しない
356 if ("https://tinyurl.com/xxxxxxxx".Length > srcUri.OriginalString.Length)
359 var content = new FormUrlEncodedContent(new[]
361 new KeyValuePair<string, string>("url", srcUri.OriginalString),
364 using var response = await this.http.PostAsync("https://tinyurl.com/api-create.php", content)
365 .ConfigureAwait(false);
367 response.EnsureSuccessStatusCode();
369 var result = await response.Content.ReadAsStringAsync()
370 .ConfigureAwait(false);
372 if (!Regex.IsMatch(result, @"^https?://"))
373 throw new WebApiException("Failed to create URL.", result);
375 return this.UpgradeToHttpsIfAvailable(new Uri(result.TrimEnd()));
378 private async Task<Uri> ShortenByIsgdAsync(Uri srcUri)
380 // 明らかに長くなると推測できる場合は短縮しない
381 if ("https://is.gd/xxxxxx".Length > srcUri.OriginalString.Length)
384 var content = new FormUrlEncodedContent(new[]
386 new KeyValuePair<string, string>("format", "simple"),
387 new KeyValuePair<string, string>("url", srcUri.OriginalString),
390 using var response = await this.http.PostAsync("https://is.gd/create.php", content)
391 .ConfigureAwait(false);
393 response.EnsureSuccessStatusCode();
395 var result = await response.Content.ReadAsStringAsync()
396 .ConfigureAwait(false);
398 if (!Regex.IsMatch(result, @"^https?://"))
399 throw new WebApiException("Failed to create URL.", result);
401 return new Uri(result.TrimEnd());
404 private async Task<Uri> ShortenByBitlyAsync(Uri srcUri, string domain = "bit.ly")
406 // 明らかに長くなると推測できる場合は短縮しない
407 if ("https://bit.ly/xxxxxxx".Length > srcUri.OriginalString.Length)
410 // OAuth2 アクセストークンまたは API キー (旧方式) のいずれも設定されていなければ短縮しない
411 if (string.IsNullOrEmpty(this.BitlyAccessToken) && (string.IsNullOrEmpty(this.BitlyId) || string.IsNullOrEmpty(this.BitlyKey)))
414 var bitly = new BitlyApi
416 EndUserAccessToken = this.BitlyAccessToken,
417 EndUserLoginName = this.BitlyId,
418 EndUserApiKey = this.BitlyKey,
421 var result = await bitly.ShortenAsync(srcUri, domain)
422 .ConfigureAwait(false);
424 return this.UpgradeToHttpsIfAvailable(result);
427 private async Task<Uri> ShortenByUxnuAsync(Uri srcUri)
429 // 明らかに長くなると推測できる場合は短縮しない
430 if ("https://ux.nu/xxxxx".Length > srcUri.OriginalString.Length)
433 var query = new Dictionary<string, string>
435 ["format"] = "plain",
436 ["url"] = srcUri.OriginalString,
439 var uri = new Uri("https://ux.nu/api/short?" + MyCommon.BuildQueryString(query));
440 using var response = await this.http.GetAsync(uri)
441 .ConfigureAwait(false);
443 response.EnsureSuccessStatusCode();
445 var result = await response.Content.ReadAsStringAsync()
446 .ConfigureAwait(false);
448 if (!Regex.IsMatch(result, @"^https?://"))
449 throw new WebApiException("Failed to create URL.", result);
451 return new Uri(result.TrimEnd());
454 private bool IsIrregularShortUrl(Uri uri)
456 // Flickrの https://www.flickr.com/photo.gne?short=... 形式のURL
457 // flic.kr ドメインのURLを展開する途中に経由する
458 if (uri.Host.EndsWith("flickr.com", StringComparison.OrdinalIgnoreCase) &&
459 uri.PathAndQuery.StartsWith("/photo.gne", StringComparison.OrdinalIgnoreCase))
465 private async Task<Uri?> GetRedirectTo(Uri url)
467 url = this.UpgradeToHttpsIfAvailable(url);
469 var request = new HttpRequestMessage(HttpMethod.Head, url);
471 using var response = await this.http.SendAsync(request)
472 .ConfigureAwait(false);
474 if (!response.IsSuccessStatusCode)
476 // ステータスコードが 3xx であれば例外を発生させない
477 if ((int)response.StatusCode / 100 != 3)
478 response.EnsureSuccessStatusCode();
481 var redirectedUrl = response.Headers.Location;
483 if (redirectedUrl == null)
486 // サーバーが URL を適切にエンコードしていない場合、OriginalString に非 ASCII 文字が含まれる。
487 // その場合、redirectedUrl は文字化けしている可能性があるため使用しない
488 // 参照: http://stackoverflow.com/questions/1888933
489 if (redirectedUrl.OriginalString.Any(x => x < ' ' || x > '~'))
492 if (redirectedUrl.IsAbsoluteUri)
493 return redirectedUrl;
495 return new Uri(url, redirectedUrl);
499 /// 指定されたURLのスキームを https:// に書き換える
501 private Uri UpgradeToHttpsIfAvailable(Uri original)
503 if (original.Scheme != "http")
506 if (InsecureDomains.Contains(original.Host))
509 var builder = new UriBuilder(original);
510 builder.Scheme = "https";
516 [SuppressMessage("Microsoft.Reliability", "CA2000:DisposeObjectsBeforeLosingScope")]
517 private static HttpClient CreateDefaultHttpClient()
519 var handler = Networking.CreateHttpClientHandler();
520 handler.AllowAutoRedirect = false;
522 var http = Networking.CreateHttpClient(handler);
523 http.Timeout = TimeSpan.FromSeconds(30);