使用 Regular Expression 驗證密碼:使用 JavaScript 的陷阱

我在前年有寫過一篇【 使用 Regular Expression 驗證密碼 】文章,當時撰寫的技巧完全是針對 .NET 提供的 Regular Expression 而寫,雖然我的文章在標籤的地方有特別提到 .NET,但還是有人將文章裡提供的 Regular Expression 直接抄去給 JavaScript 使用,結果當然是養出一堆莫名其妙的臭蟲(Bug)。

我在當時的文章中採用的 .NET Regular Expression 的右合樣(英文稱為 Lookahead 或稱為 Positive Lookahead ),該功能其實在所有瀏覽器都有支援,但是邪惡的 IE5 , IE6 , IE7 卻實做出錯誤的樣式比對規則。至於 Regular Expression 的左合樣 (Negative Lookbehind) 則是在任何瀏覽器的 JavaScript 都不支援。

當時文章的範例語法如下:

^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,30}$

而我自己設計了一個 JavaScript 單元測試案例,共有 44 個 Test Case 去測試一個較簡單的樣式,終於研究出 IE7 以下版本對於右合樣的處理行為!

如下樣式,用來比對測試的字串是否符合:至少一個小寫字母、一個數字,且最少 5 個字元以上!

^(?=.*[a-z])(?=.*\d).{5,}$

首先,這個樣式在 IE 8 , Firefox 3.6 , Google Chrome 皆可正確比對,我另外利用 IETester 測試了 IE5 , IE6 , IE7 等不同瀏覽器版本,得到的結果不太一樣,其測試結果如下:

IE8 ,Firefox 3, Chrome 3/4 IE5, IE5.5, IE6, IE7
1. Testing 12345 Failed
2. Testing abcde Failed

3. Testing a1 Failed
4. Testing a12 Failed
5. Testing a123 Failed
6. Testing a1234 OK
7. Testing a12345 OK
8. Testing a123456 OK

9. Testing ab1 Failed
10. Testing ab12 Failed
11. Testing ab123 OK
12. Testing ab1234 OK
13. Testing ab12345 OK
14. Testing ab123456 OK

15. Testing abc1 Failed
16. Testing abc12 OK
17. Testing abc123 OK
18. Testing abc1234 OK
19. Testing abc12345 OK
20. Testing abc123456 OK

21. Testing 1a Failed
22. Testing 1ab Failed
23. Testing 1abc Failed
24. Testing 1abcd OK
25. Testing 1abcde OK
26. Testing 1abcdef OK

27. Testing 12a Failed
28. Testing 12ab Failed
29. Testing 12abc OK
30. Testing 12abcd OK
31. Testing 12abcde OK
32. Testing 12abcdef OK

33. Testing 123a Failed
34. Testing 123ab OK
35. Testing 123abc OK
36. Testing 123abcd OK
37. Testing 123abcde OK
38. Testing 123abcdef OK

39. Testing 123a Failed
40. Testing 123a1 OK
41. Testing 123a1b OK
42. Testing 123a1b2 OK
43. Testing 123a1b2c OK
44. Testing 123a1b2c3 OK
1. Testing 12345 Failed
2. Testing abcde Failed

3. Testing a1 Failed
4. Testing a12 Failed
5. Testing a123 Failed
6. Testing a1234 Failed
7. Testing a12345 OK
8. Testing a123456 OK

9. Testing ab1 Failed
10. Testing ab12 Failed
11. Testing ab123 Failed
12. Testing ab1234 Failed
13. Testing ab12345 OK
14. Testing ab123456 OK

15. Testing abc1 Failed
16. Testing abc12 Failed
17. Testing abc123 Failed
18. Testing abc1234 Failed
19. Testing abc12345 OK
20. Testing abc123456 OK

21. Testing 1a Failed
22. Testing 1ab Failed
23. Testing 1abc Failed
24. Testing 1abcd Failed
25. Testing 1abcde OK
26. Testing 1abcdef OK

27. Testing 12a Failed
28. Testing 12ab Failed
29. Testing 12abc Failed
30. Testing 12abcd Failed
31. Testing 12abcde OK
32. Testing 12abcdef OK

33. Testing 123a Failed
34. Testing 123ab Failed
35. Testing 123abc Failed
36. Testing 123abcd Failed
37. Testing 123abcde OK
38. Testing 123abcdef OK

39. Testing 123a Failed
40. Testing 123a1 Failed
41. Testing 123a1b Failed
42. Testing 123a1b2 Failed
43. Testing 123a1b2c OK
44. Testing 123a1b2c3 OK


因為右、左合樣的寬度永遠為零,所以照理說處理上述樣式時 (?=.*[a-z])(?=.*\d) 樣式不應該佔用任何比對的空間,但是在 IE5, IE6, IE7 卻會佔用,因而導致樣式處理發生邏輯不正確的情況!

拿上述測試案例的 abc1234 來說,它先比對 (?=.*[a-z]) 樣式,發現沒比對到,便跳至 (?=.*\d) 樣式比對,這個樣式比對後便把 .* (即 abc 部分) 的比對給吃掉了,也就是之後的 .{5,} 樣式不會再比對 abc 這三個字元,所以光是比對 1234  就會因為不足 5 個字元而發生樣式比對失敗!

拿上述測試案例的 abc12345 來說,它先比對 (?=.*[a-z]) 樣式,發現沒比對到,便跳至 (?=.*\d) 樣式比對,這個樣式比對後便把 .* (即 abc 部分) 的比對給吃掉了,也就是之後的 .{5,} 樣式不會再比對 abc 這三個字元,接著再比對 12345  就會因為符合至少 5 個字元的樣式要求而比對成功!

另外,我也利用 Browsershots 網站幫我額外測試另外 46 個在 Windows 平台上不同種類、版本的瀏覽器,以及 56 個跨平台各主要瀏覽器,我不得不說在大多數唸的出名字的瀏覽器中只有 IE 有這種邏輯錯誤的狀況 ( 當然我們還是要給 IE8 鼓鼓掌 )。

所以若有人要在 JavaScript 中實做 Regular Expression 必須特別特別小心,否則寫出了 Bug 還不自知,孰知這個 Bug 不是開發人員的錯,但客戶總是怪我們對吧!遇到這種瀏覽器的 Bug 真是如同天色暗了、天空陰了、空氣涼了這樣的感覺。

各位有興趣也可以自行測試看看,以下是測試網頁:

再次聲明:Regular Expression 是學一次用一輩子的技能,我認為是任何程式設計師必學的技能之一。

相關連結

  

此文章由 will 發表於 2010/3/8 上午 02:17:01

永久連結 | 評論 (2) | 此文章的RSSRSS comment feed |

分類: JavaScript | Web

標籤: , ,

收藏:

如何讓 IIS6 / IIS7 中同站台不同應用程式間共用 Session 資料

在 Web Farm 環境下部署網站需要關注的細節可不少,在部署大型網站的時候 IIS 這部分到底要算 IT 的領域還是開發人員(Developer)的領域其實分不太清楚,像要在「同一個站台」區分「不同應用程式」且還要能讓 Session 彼此互通,這到底應該歸誰管呢?這可不是用「ASP.NET 開發伺服器」可以模擬出來的,而 IT 人員如果不會寫程式應該也不知該如何是好,這也是我認為 ASP.NET 開發人員應該多熟悉 IIS 的原因。

要在 Web Farm 環境下要達成 Session 互通有以下條件:

  1. 確保 <machineKey> 要設定一致
  2. 確保各站台在 IIS 中 metabase 定義的「應用程式路徑」必須一致
  3. 使用者的 Session Cookie 的 名稱/值 必須一致 (不能跨越不同的網域網址不同的父網域網址)

這三個條件是我從四處的文件與實際經驗累積而來的結果,而其中最不容易發現的是「應用程式路徑」必須一致這個條件。

例如在 IIS 中,預設站台的 ID 為 1,在站台根目錄這個預設用程式的「應用程式路徑」為:

/LM/W3SVC/1/ROOT

若你在 Web Farm 環境下的第二台伺服器,除了原本的預設站台外,再建立另一個新的站台,則該站台的 ID 為 2,其「應用程式路徑」為:

/LM/W3SVC/2/ROOT

那麼這兩個站台便無法共用 Session,即便 Session 儲存後端用 SQL ServerASP.NET 狀態服務 且也設定好相同的 <machineKey> 也一樣無法互通 Session 資料。

基於這個規則,在架設 Web Farm 網站時就必須特別小心設定,否則可能查設定查到天荒地老也不知道為什麼會這樣,但在不太複雜的網站下,通常多台 Web Farm 主機下也各別只會有一個站台,所以也不一定會遇到這狀況。

那麼在「同一個站台」下「不同應用程式」時,其實就是「兩個應用程式」,我們可以利用 appcmd 指令列工具測試一下:

C:\Windows\System32\inetsrv>appcmd list app
APP "Default Web Site/" (applicationPool:DefaultAppPool)
APP "Default Web Site/WebSite2" (applicationPool:DefaultAppPool)

以上述為例,在 Default Web Site 站台下另外新增了一個「應用程式」為 /WebSite2,其「應用程式路徑」分別為:

/LM/W3SVC/1/ROOT
/LM/W3SVC/1/ROOT/WebSite2

所以這兩個應用程式基本上是無法互通 Session 的,但你可能會納悶在同一個站台下為何 Session 無法互通,因為在 .NET Framework 裡的 System.Web.SessionState 命名空間已經預先設定了取得 Session 資料的邏輯,所以這是 by design 的情況,程式碼並無法修改。

 

這些屬性無法透過「正規」的管道進行修正或調整,所以「表面上」似乎無解,除非你自行撰寫新的 Session 機制並將 IIS 中內建的 Session Module 抽換掉,但這實在太累人了,沒必要重新發明輪子。

還好在 .NET 有 Reflection (反映) 機制,透過自訂的 HttpModule 可以讓 SessionStateModule 模組被載入之前動態將 System.Web.HttpRuntime 型別中的 _appDomainAppId 的值換成我們希望的值,這樣就可以讓不同應用程式之間共用 Session 資料了,程式碼如下:

FieldInfo runtimeInfo = typeof(HttpRuntime).GetField("_theRuntime", 
BindingFlags.Static | BindingFlags.NonPublic);

HttpRuntime theRuntime = (HttpRuntime)runtimeInfo.GetValue(null);

FieldInfo appDomainAppIdInfo = typeof(HttpRuntime).GetField("_appDomainAppId",
BindingFlags.Instance | BindingFlags.NonPublic);

appDomainAppIdInfo.SetValue(theRuntime, "SharedAppDomainAppId");

假設我們這個 HttpModule 的類別名稱為 SharedSessionModule 並放置在 App_Code 動態編譯目錄下,我們就可以修改 web.config 將我們自訂的 SharedSessionModule 註冊到 IIS6 的 <httpModules> 區段,或 IIS7 的 <modules> 區段中。

IIS6

<httpModules>
<add name="SharedSessionModule" type="SharedSessionModule, App_Code"/>
</httpModules>

IIS7

<modules>
<remove name="Session" />
<add name="SharedSessionModule" type="SharedSessionModule, App_Code"/>
<add name="Session1" type="System.Web.SessionState.SessionStateModule,
System.Web, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
/>
</modules>

注意: 在 IIS7 中由於 整合管線(Integrated Pipeline) 的關係 HttpModule 的載入順序有些應該注意的地方,你不能使用在 applicationHost.config 中已經定義過的模組名稱,否則將無法調整載入模組的順序,所以才必須「先移除 Session」再「載入 SharedSessionModule」然後再「重新載入 SessionStateModule 並命名為 Session1」才行。如果預設的 SessionStateModule 模組在 SharedSessionModule 之前先執行,在 SessionStateModule 模組完成初始化動作後 SharedSessionModule 再做任何修改就沒有作用了。

相關連結

  

此文章由 will 發表於 2010/1/24 下午 02:02:10

永久連結 | 評論 (7) | 此文章的RSSRSS comment feed |

分類: Web | ASP.NET | IIS

標籤: , , , ,

收藏:

利用 WebClient 類別模擬 HTTP POST 表單送出的注意事項

我們都知道 WebClient 類別是個簡單易用的東西,不只可以用作 HTTP 用途,連 FTP 都能用,想偷懶時很快就能寫出一些網路資料上傳、下載的程式,像我在寫一些測試程式時經常會使用 WebClient 類別,但大多情況都用來「下載網頁」居多,少有模擬表單上傳資料的情況,但利用 WebClient 類別在「傳送表單資料」時要小心使用,否則遠端接不到資料又很難除錯時哪就麻煩了。

與「上傳資料」有關的方法有以下四種 (不考慮非同步模式的方法)

如果是你,你會選用哪一種呢?你會不會覺得 string 比較友善呢? ^__^

像我們之前就這樣寫,以為可以順利的將資料上傳(錯誤示範):

using (WebClient wc = new WebClient())
{
  try
  {
	  wc.Encoding = Encoding.UTF8;

	  string resultXML = wc.UploadString(Config.PostURL, 
		  String.Format("id={0}&pw={1}", user.ID, user.PW));
  }
  catch (WebException ex)
  {
	  throw new Exception("無法連接遠端伺服器");
  }
}

透過這段程式所送出的 HTTP Request Header 如下:

POST /Home/Echo HTTP/1.1
Host: my.example.com:52215
Content-Length: 13
Connection: Keep-Alive

id=aaa&pw=bbb

雖然一樣是 POST 方法,但是缺少了最重要的 Content-Type 標頭,也就是:

Content-Type: application/x-www-form-urlencoded

以我們常用 ASP.NET MVC 為例,你不傳送標準的 Content-Type 過去,就無法正確抓到所有表單資料,也無法透過 Model Binder 自動繫結到 Action 參數中!

雖然你可以利用 wc.Headers 屬性自行將缺少的 Header 加上去,例如:

wc.Headers.Add("Content-Type", "application/x-www-form-urlencoded");

但後來我們改用 UploadValues 方法,程式碼範例如下:

using (WebClient wc = new WebClient())
{
  try
  {
      wc.Encoding = Encoding.UTF8;
      
      NameValueCollection nc = new NameValueCollection();
      nc["id"] = "aaa";
      nc["pw"] = "bbb";
      
      byte[] bResult = wc.UploadValues(Config.PostURL, nc);
      
      string resultXML = Encoding.UTF8.GetString(bResult);
  }
  catch (WebException ex)
  {
      throw new Exception("無法連接遠端伺服器");
  }
}

雖然多了幾行 Code,但是其實語法更容易理解,而且也不用自己組 POST 資料中類似 QueryString 的字串,還會自動做 URLEncode 動作,只是最後多一個步驟要將 byte[] 轉回 string 而已,這段程式最後會送出的 HTTP Request Header 如下:

POST /Home/Echo HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Host: my.example.com:52215
Content-Length: 13
Connection: Keep-Alive

id=aaa&pw=bbb

但是,使用 WebClient 類別其實有許多缺點,所以我文章一開頭就說過「想偷懶時」,主要的缺點有:

  • 無法指定 Timeout,所以當網路連不上時,網頁或程式會整個停在那裡很久!
  • 不適合用來下載大量的檔案,高負載的網站也不適合這樣用,即便你用非同步的方式撰寫,也會讓 WebClient 因為佔據過多 Threads 而導致效能降低,壹台電腦的 Threads 數是有限制的。

對於較正式的場合,還是建議改用 HttpWebRequest 類別處理。

相關連結

  

此文章由 will 發表於 2010/1/23 下午 01:34:45

永久連結 | 評論 (5) | 此文章的RSSRSS comment feed |

分類: Web | .Net | ASP.NET MVC | ASP.NET

標籤: , , ,

收藏:

使用 Google Public DNS 服務,上網速度不一定會變快!

這幾天 Google Public DNS 被炒的火熱,漂亮的 IP 位址 ( 8.8.8.8 , 8.8.4.4 ) 被瘋狂的宣傳,連我也一頭熱的研究了一番,一開始覺得速度還挺快的,網路延遲時間也很短,我猜也許已經有人開始將個人電腦的 DNS 切換過去了,但對於 168.95.1.1 的琵琶別抱真的是好事嗎,以下些許淺見供參。

Google Public DNS 官網中指出三個使用 Google Public DNS 最主要的理由:

  • 加速瀏覽體驗 ( Speed up your browsing experience )
    白話文:上網速度會比較快
  • 提升網路安全 ( Improve your security )
    白話文:Google Public DNS 上網會比較安全
  • 直接取得 DNS 查詢結果 ( Get the results you expect with absolutely no redirection )
    白話文:用它來查 DNS 比較快就對了,它不會轉向去查上層 DNS 的紀錄 (因為已經快取了)

關於 Google Public DNS 的介紹網路上已經一大堆,隨便 Google 一下就會查到許多資料,應該不用我多做一些基本的設定教學,但我可以分享一些這幾天研究的心得,對於「提升安全性」的部分無庸置疑,用國際大廠的服務真的有許多好處,此篇文章主要的著眼點在主要在於效能部分。

§ 關於 Google Public DNS 的效能 ( Performance Benefits )

DNS 的查詢速度著實會影響上網速度,主要影響 DNS 查詢速度的關鍵因素有兩項:

  1. 用戶端電腦主要 DNS 伺服器 之間的網路延遲:這部分的網路延遲大多與 round-trip time (RTT) 的限制有很大的關係,例如:兩個端點間的距離、網路壅塞的程度、封包傳遞的品質、過載的伺服器、阻斷服務攻擊、…等等都會影響網路延遲的時間長度。
  2. 主要 DNS 伺服器其他 DNS 伺服器 之間的網路延遲,這部分又包括三個成分:
    1. 快取失效(Cache misses):熟知 DNS 運作原理的人都知道當主要 DNS 沒有查詢目標的 DNS 快取紀錄時就會遞迴的向其他 DNS 伺服器解析網域名稱,這種狀況難以避免,當 DNS 的上層伺服器(權威伺服器)離主要伺服器很遠時,網路延遲就會一層一層的加上去,難保 DNS 的效率低落,導致上網速度降低,而「快取失效」這一點這也是 Google 認為是影響 DNS 查詢效率最關鍵的因素。
    2. 伺服器無法負荷要求(Underprovisioning):當 DNS 伺服器部署不夠數量無法負荷用戶端大量的 DNS 查詢時,通常會讓 DNS Client 等待過久,導致查詢失敗進而重新發送封包查詢 ( DNS 是走 UDP 協定 ),如果有這種狀況發生,也會大幅影響網域解析的時間。
    3. 惡意網路流量(Malicious traffic):即便 DNS 伺服器部署足夠,阻斷服務攻擊(DoS)也可能會造成現有的 DNS 伺服器大量的負載,以導致伺服器無法有效回應或回應惡意的 IP 位址。我去年寫的一篇文章【小心網域名稱伺服器快取毒害(DNS cache poisoning)攻擊 】就是其中一種 DNS 攻擊的形式,不但會癱瘓 DNS 伺服器,還有可能會導致 DNS 伺服器回應惡意的 IP 位址。

Google 認為 快取失效(Cache misses) 是影響 DNS 查詢效能最主要的因素,他們提供幾點改善的方案:

  1. 部署足夠的 DNS 伺服器 (Provisioning serving clusters adequately)
  2. 避免阻斷服務攻擊擴大規模 (Preventing DoS and amplification attacks)
  3. 有效利用負載平衡機制將 DNS 集中快取 (Load-balancing for shared caching)
    1. 將常用的 DNS 名稱解析集中處理快取,讓全球的 DNS 伺服器統一取得快取更新資料
    2. 將不常用的 DNS 名稱依據「DNS名稱」分散快取,以分散分享式快取的流量
  4. 積極的預先取得名稱解析 (Prefetching name resolutions)
  5. 在全球部署DNS伺服器以提供服務 (Distributing serving clusters for wide geographical coverage)

在台灣,也許你會用 HiNet 的 DNS 服務,眾所周知的 168.95.1.1 已經是家喻戶曉的 IP 位址,幾乎大部分 IT 人員都是用這個 IP 作為主要 DNS 伺服器,就連有些非 HiNET 的 IDC 甚至還會建議客戶直接使用 HiNet DNS 作為主要伺服器,相信我,用它準沒錯,他要是掛了,全台灣差不多有 80% 網路會斷線,這就跟大公司會買 IBM 伺服器或 Cisco 路由器的理由一樣:「他要是有問題我也沒輒」。

對於 Google 這種對於全球公開的 DNS 伺服器來說,那可沒那麼簡單,由於全球網路如此之大,Google 不可能在每一個國家、每一個地區都有機房,你確定你用的 8.8.8.8 真的離你很近嗎?或許是!但你確定在 Google Public DNS 中被快取的 IP 真的是離你最近的 IP 嗎?那可不一定!

CDN (Content delivery network) (內容傳遞網路) 機制是我最近研究的網路技術之一,CDN 利用許多機制降低 用戶端伺服器端 之間的網路延遲,大多數 CDN 業者都至少會同時實做好幾種機制,例如 Anycast (BGP), Global Server Load Balancing, DNS-based request routing, Dynamic metafile generation, HTML rewriting, … 等等。( 備註:Google Public DNS 使用 Anycast routing ) 其中的 DNS-based request routing 機制最為常見,CDN 業者提供的 DNS 伺服器會利用 來源 DNS 解析伺服器 的 IP 位址判斷出來源 IP 的地理位置,再依據 IP 所在地理位置來選擇回應由來源 IP 位置連線到他們全球伺服器中最接近該位置的伺服器,以降低網路延遲時間。

由於這個機制會依據 來源 DNS 解析伺服器 的 IP 位址判斷出來源 IP 的地理位置,所以若採用 Google Public DNS 來解析 IP 位址時就不一定能得到離你最近的 IP 位址,因此連 Google 本身也無法保證在這種情況下你能得到的最有效率的瀏覽體驗!請看以下紅框標注的段落:

Distributing serving clusters for wide geographical coverage: Note, however, that because nameservers geolocate according to the resolver's IP address rather than the user's, Google Public DNS has the same limitations as other open DNS services: that is, the server to which a user is referred might be farther away than one to which a local DNS provider would have referred. This could cause a slower browsing experience for certain sites.

你覺得採用 CDN 機制的網站很少嗎?其實只要是跨國且超大流量的網站幾乎都會採用 CDN 機制,所以你常用的 Hotmail, Facebook, Plurk, Twitter, … 等網站全部都採用 CDN 分散網路流量,但若透過 Google Public DNS 來上網,難保你能得到最佳瀏覽體驗!

不過,這並非絕對,只能說「無法保證」而已,而且 CDN 能實做的機制非常多,不止 DNS-based request routing 而已,所以並非所有 CDN 網路都會因為 Google Public DNS 而導致瀏覽速度降低,還是有許多 DNS 以外的優化方案,不然你想想看全球採用單一 8.8.8.8 這個 IP 都可以讓各地的網路延遲時間都可以低於 50ms 是什麼道理,網路世界博大精深,不是一篇文章可以講完的 ^^

也許有些比較小咖的 CDN 業者或自行實做 GeoDNS 的網站,可能就會遭受 Google Public DNS 的荼毒,所以到底要推廣 Google Public DNS 還是不要推廣呢?這真是個難為的決定阿!不過至少我短期內不會採用。

相關連結

  

此文章由 will 發表於 2009/12/8 上午 10:26:14

永久連結 | 評論 (6) | 此文章的RSSRSS comment feed |

分類: Web | 心得分享 | 系統管理

標籤: , , , ,

收藏:

如何自動取得所有前端 JavaScript 錯誤以提升網站品質

我們每個案子都會用許多錯誤追蹤的機制確保網站品質,但還是不時會讓客戶發現有些功能無法操作,而這些問題大多出在 JavaScript 錯誤,由於一些老舊的瀏覽器或不同瀏覽器特性不同的關係,難免會有不小心寫錯的時候,為了能有效追蹤這些不易察覺的錯誤,我們會利用 JavaScript 追蹤 JavaScript 錯誤!

這個需求透過 jQuery 來做最方便了,透過 error(fn) 事件即可達成此一目標:

$(window).error(function(msg, url, line){
jQuery.post("/js_error_log.ashx", { msg: msg, url: url, line: line });
});

透過這個技巧即可有效獲得即時的前端資訊,看你要儲存到資料庫、EventLog、File、或郵寄出來都可以,不過唯一要小心的地方是當錯誤非常多時,這個錯誤追蹤的量可能會非常大,這時要特別注意程式的寫法,不要因為前端的錯誤槁掛了你的伺服器,而且有查到錯誤就要立即處理,降低錯誤發生的次數與提升網站品質。

相關連結

  

此文章由 will 發表於 2009/12/7 下午 12:08:01

永久連結 | 評論 (5) | 此文章的RSSRSS comment feed |

分類: Tips | JavaScript | Web

標籤: , ,

收藏: