// OpenTween - Client of Twitter // Copyright (c) 2007-2011 kiri_feather (@kiri_feather) // (c) 2008-2011 Moz (@syo68k) // (c) 2008-2011 takeshik (@takeshik) // (c) 2010-2011 anis774 (@anis774) // (c) 2010-2011 fantasticswallow (@f_swallow) // (c) 2011 kim_upsilon (@kim_upsilon) // All rights reserved. // // This file is part of OpenTween. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General public License as published by the Free // Software Foundation; either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General public License // for more details. // // You should have received a copy of the GNU General public License along // with this program. If not, see , or write to // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, // Boston, MA 02110-1301, USA. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Net.Http; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Web; using OpenTween.Api; using OpenTween.Connection; namespace OpenTween { /// /// 短縮 URL サービスによる URL の展開・短縮を行うクラス /// public class ShortUrl { private static Lazy _instance; /// /// ShortUrl のインスタンスを取得します /// public static ShortUrl Instance { get { return _instance.Value; } } /// /// 短縮 URL の展開を無効にするか否か /// public bool DisableExpanding { get; set; } /// /// 短縮 URL のキャッシュを定期的にクリアする回数 /// public int PurgeCount { get; set; } public string BitlyAccessToken { get; set; } public string BitlyId { get; set; } public string BitlyKey { get; set; } private HttpClient http; private ConcurrentDictionary urlCache = new ConcurrentDictionary(); private static readonly Regex HtmlLinkPattern = new Regex(@"( ShortUrlHosts = new HashSet { "4sq.com", "airme.us", "amzn.to", "bctiny.com", "bit.ly", "bkite.com", "blip.fm", "budurl.com", "buff.ly", "cli.gs", "digg.com", "disq.us", "dlvr.it", "fb.me", "feedly.com", "feeds.feedburner.com", "ff.im", "flic.kr", "goo.gl", "ht.ly", "htn.to", "icanhaz.com", "ift.tt", "is.gd", "j.mp", "linkbee.com", "moby.to", "moi.st", "nico.ms", "nsfw.in", "on.fb.me", "ow.ly", "p.tl", "pic.gd", "qurl.com", "rubyurl.com", "snipurl.com", "snurl.com", "t.co", "tinami.jp", "tiny.cc", "tinyurl.com", "tl.gd", "tmblr.co", "traceurl.com", "tumblr.com", "twitthis.com", "twme.jp", "twurl.nl", "u-rl.jp", "urlenco.de", "urx2.nu", "ustre.am", "ux.nu", "wp.me", "www.qurl.com", "www.tumblr.com", "youtu.be", }; static ShortUrl() { _instance = new Lazy(() => new ShortUrl(), true); } [SuppressMessage("Microsoft.Reliability", "CA2000:DisposeObjectsBeforeLosingScope")] internal ShortUrl() : this(CreateDefaultHttpClient()) { Networking.WebProxyChanged += (o, e) => { var newClient = CreateDefaultHttpClient(); var oldClient = Interlocked.Exchange(ref this.http, newClient); oldClient.Dispose(); }; } internal ShortUrl(HttpClient http) { this.DisableExpanding = false; this.PurgeCount = 500; this.BitlyId = ""; this.BitlyKey = ""; this.http = http; } [Obsolete] public string ExpandUrl(string uri) { try { return this.ExpandUrlAsync(new Uri(uri), 10).Result.AbsoluteUri; } catch (UriFormatException) { return uri; } } /// /// 短縮 URL を非同期に展開します /// /// 展開するURL /// URLの展開を行うタスク public Task ExpandUrlAsync(Uri uri) { return this.ExpandUrlAsync(uri, 10); } /// /// 短縮 URL を非同期に展開します /// /// 展開するURL /// 再帰的に展開を試みる上限 /// URLの展開を行うタスク public async Task ExpandUrlAsync(Uri uri, int redirectLimit) { if (this.DisableExpanding) return uri; if (redirectLimit <= 0) return uri; if (!uri.IsAbsoluteUri) return uri; try { if (!ShortUrlHosts.Contains(uri.Host) && !IsIrregularShortUrl(uri)) return uri; if (this.urlCache.TryGetValue(uri, out var expanded)) return expanded; if (this.urlCache.Count > this.PurgeCount) this.urlCache.Clear(); expanded = null; try { expanded = await this.GetRedirectTo(uri) .ConfigureAwait(false); } catch (TaskCanceledException) { } catch (HttpRequestException) { } if (expanded == null || expanded == uri) return uri; this.urlCache[uri] = expanded; var recursiveExpanded = await this.ExpandUrlAsync(expanded, --redirectLimit) .ConfigureAwait(false); // URL1 -> URL2 -> URL3 のように再帰的に展開されたURLを URL1 -> URL3 としてキャッシュに格納する if (recursiveExpanded != expanded) this.urlCache[uri] = recursiveExpanded; return recursiveExpanded; } catch (UriFormatException) { return uri; } } /// /// 短縮 URL を非同期に展開します /// /// /// 不正なURLが渡された場合は例外を投げず uriStr をそのまま返します /// /// 展開するURL /// URLの展開を行うタスク public Task ExpandUrlAsync(string uriStr) { return this.ExpandUrlAsync(uriStr, 10); } /// /// 短縮 URL を非同期に展開します /// /// /// 不正なURLが渡された場合は例外を投げず uriStr をそのまま返します /// /// 展開するURL /// 再帰的に展開を試みる上限 /// URLの展開を行うタスク public async Task ExpandUrlAsync(string uriStr, int redirectLimit) { Uri uri; try { if (!uriStr.StartsWith("http", StringComparison.OrdinalIgnoreCase)) uri = new Uri("http://" + uriStr); else uri = new Uri(uriStr); } catch (UriFormatException) { return uriStr; } var expandedUri = await this.ExpandUrlAsync(uri, redirectLimit) .ConfigureAwait(false); return expandedUri.OriginalString; } [Obsolete] public string ExpandUrlHtml(string html) { return this.ExpandUrlHtmlAsync(html, 10).Result; } /// /// HTML内に含まれるリンクのURLを非同期に展開する /// /// 処理対象のHTML /// URLの展開を行うタスク public Task ExpandUrlHtmlAsync(string html) { return this.ExpandUrlHtmlAsync(html, 10); } /// /// HTML内に含まれるリンクのURLを非同期に展開する /// /// 処理対象のHTML /// 再帰的に展開を試みる上限 /// URLの展開を行うタスク public Task ExpandUrlHtmlAsync(string html, int redirectLimit) { if (this.DisableExpanding) return Task.FromResult(html); return HtmlLinkPattern.ReplaceAsync(html, async m => m.Groups[1].Value + await this.ExpandUrlAsync(m.Groups[2].Value, redirectLimit).ConfigureAwait(false) + m.Groups[3].Value); } /// /// 指定された短縮URLサービスを使用してURLを短縮します /// /// 使用する短縮URLサービス /// 短縮するURL /// 短縮されたURL public async Task ShortenUrlAsync(MyCommon.UrlConverter shortenerType, Uri srcUri) { // 既に短縮されている状態のURLであれば短縮しない if (ShortUrlHosts.Contains(srcUri.Host)) return srcUri; try { switch (shortenerType) { case MyCommon.UrlConverter.TinyUrl: return await this.ShortenByTinyUrlAsync(srcUri) .ConfigureAwait(false); case MyCommon.UrlConverter.Isgd: return await this.ShortenByIsgdAsync(srcUri) .ConfigureAwait(false); case MyCommon.UrlConverter.Twurl: return await this.ShortenByTwurlAsync(srcUri) .ConfigureAwait(false); case MyCommon.UrlConverter.Bitly: return await this.ShortenByBitlyAsync(srcUri, "bit.ly") .ConfigureAwait(false); case MyCommon.UrlConverter.Jmp: return await this.ShortenByBitlyAsync(srcUri, "j.mp") .ConfigureAwait(false); case MyCommon.UrlConverter.Uxnu: return await this.ShortenByUxnuAsync(srcUri) .ConfigureAwait(false); default: throw new ArgumentException("Unknown shortener.", nameof(shortenerType)); } } catch (OperationCanceledException) { // 短縮 URL の API がタイムアウトした場合 return srcUri; } } private async Task ShortenByTinyUrlAsync(Uri srcUri) { // 明らかに長くなると推測できる場合は短縮しない if ("http://tinyurl.com/xxxxxx".Length > srcUri.OriginalString.Length) return srcUri; var content = new FormUrlEncodedContent(new[] { new KeyValuePair("url", srcUri.OriginalString), }); using (var response = await this.http.PostAsync("http://tinyurl.com/api-create.php", content).ConfigureAwait(false)) { response.EnsureSuccessStatusCode(); var result = await response.Content.ReadAsStringAsync() .ConfigureAwait(false); if (!Regex.IsMatch(result, @"^https?://")) throw new WebApiException("Failed to create URL.", result); return new Uri(result.TrimEnd()); } } private async Task ShortenByIsgdAsync(Uri srcUri) { // 明らかに長くなると推測できる場合は短縮しない if ("http://is.gd/xxxx".Length > srcUri.OriginalString.Length) return srcUri; var content = new FormUrlEncodedContent(new[] { new KeyValuePair("format", "simple"), new KeyValuePair("url", srcUri.OriginalString), }); using (var response = await this.http.PostAsync("http://is.gd/create.php", content).ConfigureAwait(false)) { response.EnsureSuccessStatusCode(); var result = await response.Content.ReadAsStringAsync() .ConfigureAwait(false); if (!Regex.IsMatch(result, @"^https?://")) throw new WebApiException("Failed to create URL.", result); return new Uri(result.TrimEnd()); } } private async Task ShortenByTwurlAsync(Uri srcUri) { // 明らかに長くなると推測できる場合は短縮しない if ("http://twurl.nl/xxxxxx".Length > srcUri.OriginalString.Length) return srcUri; var content = new FormUrlEncodedContent(new[] { new KeyValuePair("link[url]", srcUri.OriginalString), }); using (var response = await this.http.PostAsync("http://tweetburner.com/links", content).ConfigureAwait(false)) { response.EnsureSuccessStatusCode(); var result = await response.Content.ReadAsStringAsync() .ConfigureAwait(false); if (!Regex.IsMatch(result, @"^https?://")) throw new WebApiException("Failed to create URL.", result); return new Uri(result.TrimEnd()); } } private async Task ShortenByBitlyAsync(Uri srcUri, string domain = "bit.ly") { // 明らかに長くなると推測できる場合は短縮しない if ("http://bit.ly/xxxx".Length > srcUri.OriginalString.Length) return srcUri; // OAuth2 アクセストークンまたは API キー (旧方式) のいずれも設定されていなければ短縮しない if (string.IsNullOrEmpty(this.BitlyAccessToken) && (string.IsNullOrEmpty(this.BitlyId) || string.IsNullOrEmpty(this.BitlyKey))) return srcUri; var bitly = new BitlyApi { EndUserAccessToken = this.BitlyAccessToken, EndUserLoginName = this.BitlyId, EndUserApiKey = this.BitlyKey, }; return await bitly.ShortenAsync(srcUri, domain) .ConfigureAwait(false); } private async Task ShortenByUxnuAsync(Uri srcUri) { // 明らかに長くなると推測できる場合は短縮しない if ("http://ux.nx/xxxxxx".Length > srcUri.OriginalString.Length) return srcUri; var query = new Dictionary { ["format"] = "plain", ["url"] = srcUri.OriginalString, }; var uri = new Uri("http://ux.nu/api/short?" + MyCommon.BuildQueryString(query)); using (var response = await this.http.GetAsync(uri).ConfigureAwait(false)) { response.EnsureSuccessStatusCode(); var result = await response.Content.ReadAsStringAsync() .ConfigureAwait(false); if (!Regex.IsMatch(result, @"^https?://")) throw new WebApiException("Failed to create URL.", result); return new Uri(result.TrimEnd()); } } private bool IsIrregularShortUrl(Uri uri) { // Flickrの https://www.flickr.com/photo.gne?short=... 形式のURL // flic.kr ドメインのURLを展開する途中に経由する if (uri.Host.EndsWith("flickr.com", StringComparison.OrdinalIgnoreCase) && uri.PathAndQuery.StartsWith("/photo.gne", StringComparison.OrdinalIgnoreCase)) return true; return false; } private async Task GetRedirectTo(Uri url) { var request = new HttpRequestMessage(HttpMethod.Head, url); using (var response = await this.http.SendAsync(request).ConfigureAwait(false)) { if (!response.IsSuccessStatusCode) { // ステータスコードが 3xx であれば例外を発生させない if ((int)response.StatusCode / 100 != 3) response.EnsureSuccessStatusCode(); } var redirectedUrl = response.Headers.Location; if (redirectedUrl == null) return null; // サーバーが URL を適切にエンコードしていない場合、OriginalString に非 ASCII 文字が含まれる。 // その場合、redirectedUrl は文字化けしている可能性があるため使用しない // 参照: http://stackoverflow.com/questions/1888933 if (redirectedUrl.OriginalString.Any(x => x < ' ' || x > '~')) return null; if (redirectedUrl.IsAbsoluteUri) return redirectedUrl; else return new Uri(url, redirectedUrl); } } [SuppressMessage("Microsoft.Reliability", "CA2000:DisposeObjectsBeforeLosingScope")] private static HttpClient CreateDefaultHttpClient() { var handler = Networking.CreateHttpClientHandler(); handler.AllowAutoRedirect = false; var http = Networking.CreateHttpClient(handler); http.Timeout = TimeSpan.FromSeconds(30); return http; } } }