2020/08/14

The SameSite attribute of the Set-Cookie in WebBroker Response

前陣子在陪好友練習 Cookie 操作時,無意間發現瀏覽器跳出一個奇怪的警告訊息:

一查才發現原來是瀏覽器為了防止儲存的 Cookie 被送到其它網站服務器所作的安全機制。

若現在不理它,未來可能會被瀏覽器無視,所以先來了解要如何解決。

SameSite 定義

MDN 中對 sameSite 說明如下:

Values

The SameSite attribute accepts three values:

Lax(一般;預設值)

Cookies are allowed to be sent with top-level navigations and will be sent along with GET request initiated by third party website. This is the default value in modern browsers.

Strict(最嚴格)

Cookies will only be sent in a first-party context and not be sent along with requests initiated by third party websites.

None(不限制)

Cookies will be sent in all contexts, i.e sending cross-origin is allowed.

None used to be the default value, but recent browser versions made Lax the default value to have reasonably robust defense against some classes of cross-site request forgery (CSRF) attacks.

None requires the Secure attribute in latest browser versions. See below for more information.

雖然沒有說明是否為必須設定,而且預設值是 Lax,現在不處理好像也沒關係。

程式解法

依照 iTHome 【Chrome 80將採用新的Cookie安全模型預設無法跨站存取Cookie】報導指出,不是每一家函式庫和語言都有支援 SameSite,像 Delphi 的 WebBroker 就沒有……

PHP 則是到 7.3 版之後有內建支援:

PHP 7.2 以前 =  header(‘Set-Cookie: cross-site-cookie=name; SameSite=None; Secure’);

PHP 7.3 = setcookie(‘cross-site-cookie’, ‘name’, [‘samesite’ => ‘None’, ‘secure’ => true]);

PHP 都有這種問題,那 Delphi 應該也不例外,也就是說,PHP 7.2 以前的寫法很值得參考。

回到 Delphi,和 Cookie 有關的設定在 HTTPApp 單元裡的 TCookie 類別:

其中的 GetHeaderValue 就是 TCookie 兜出等同 PHP 7.2 Response 自訂 header 的內容:

function TCookie.GetHeaderValue: AnsiString; var S: string; begin S := Format('%s=%s; ', [HTTPEncode(FName), HTTPEncode(FValue)]); if Domain <> '' then S := S + Format('domain=%s; ', [Domain]); { do not localize } if Path <> '' then S := S + Format('path=%s; ', [Path]); { do not localize } if Expires > -1 then S := S + Format(FormatDateTime('"expires="' + sDateFormat + ' "GMT; "', Expires), { do not localize } [DayOfWeekStr(Expires), MonthStr(Expires)]); if Secure then S := S + 'secure'; { do not localize } if Copy(S, Length(S) - 1, MaxInt) = '; ' then SetLength(S, Length(S) - 2); Result := AnsiString(S); end;

以這個基礎,改寫如下:

function GetHeaderValue(FName, FValue:string; Domain:string=''; Path:string=''; SameSite: string='Lax'; Expires: TDateTime=-1; Secure: Boolean=False): string; var S: string; begin S := Format('%s=%s; ', [HTTPEncode(FName), HTTPEncode(FValue)]); if Domain <> '' then S := S + Format('domain=%s; ', [Domain]); { do not localize } if Path <> '' then S := S + Format('path=%s; ', [Path]); { do not localize } if Expires > -1 then S := S + Format(FormatDateTime('"expires="' + sDateFormat + ' "GMT; "', Expires), { do not localize } [DayOfWeekStr(Expires), MonthStr(Expires)]); if SameSite='' then S := S + 'SameSite=None;' else S := S + Format('SameSite=%s;', [SameSite]); if Secure then S := S + 'secure'; { do not localize } if Copy(S, Length(S) - 1, MaxInt) = '; ' then SetLength(S, Length(S) - 2); Result := (S); end;

增加了對 SameSite 的支援,對現代的瀏覽器支援又更進一步。

See also

2020/08/08

跨來源資源共用CORS處理方式(一)

 

寫對外公開 WebAPI 時,會因為瀏覽器安全性的關係而禁止非同源存取。

如以下範例:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">

<html>
  <head>
    <title></title>
    <script type="text/javascript" src="js/base64.js"></script>
    <script type="text/javascript" src="js/json-min.js"></script>
    <script type="text/javascript" src="js/serverfunctionexecutor.js"></script>
    <script type="text/javascript" src="js/connection.js"></script>
  </head>

  <body>
   <form onsubmit="event.preventDefault();myFunction()">Enter name: <input type="TEXT"> Enter password: <input type="PASSWORD">
    <input type="submit" value="Login">

</form>
    <script>
      "use strict"
      function myFunction() {
        var request = getXmlHttpObject()
        request.open("GET", "http://localhost:8080/datasnap/rest/TServerMethods1/EchoString/A B C", true);
        request.onreadystatechange = function() {
          if (request.readyState == 4)
          {
            var JSONResult = parseHTTPResponse(request)
            console.log(JSONResult.result[0])
          }
        }
        request.setRequestHeader("Accept", "application/json");
        request.setRequestHeader("Content-Type", "text/plain;charset=UTF-8");
        request.setRequestHeader("If-Modified-Since", "Mon, 1 Oct 1990 05:00:00 GMT");
        request.send();
      }
    </script>
  </body>
</html>
在這裡模擬 XMLHttpRequest 對公開 WebAPI 進行登入,接著瀏覽器很不客氣的回應了:

 

再看看網路頁籤是如何發送 XHR 的訊息:

MDN網站是這麼解釋的:

跨來源資源共用標準的運作方式是藉由加入新的 HTTP 標頭讓伺服器能夠描述來源資訊以提供予瀏覽器讀取。另外,針對會造成副作用的 HTTP 請求方法(特別是 GET 以外的 HTTP 方法,或搭配某些 MIME typesPOST 方法),規範要求瀏覽器必須要請求傳送「預檢(preflight)」請求,以 HTTP 的 OPTIONS 方法之請求從伺服器取得其支援的方法。當伺服器許可後,再傳送 HTTP 請求方法送出實際的請求。伺服器也可以通知客戶端是否要連同安全性資料(包括 Cookies 和 HTTP 認證(Authentication)資料)一併隨請求送出。

於是要在後端讓 WebAPI 修正到能接受 OPTIONS 請求。

第二次發送,成功了……沒有,但回傳另一個錯誤訊息:

缺少 Access-Control-Allow-Origin 檔頭,原來如此,這檔頭又要表示什麼呢?MDN 是這麼解釋的:

Origin 標頭與 Access-Control-Allow-Origin 標頭中可以看到存取控制協定最簡單的用途。這個例子中,伺服器回傳 Access-Control-Allow-Origin: * 表示允許任何網域跨站存取資源,倘若 http://bar.other 的資源擁有者只准許來自 http://foo.example 的存取資源請求,那麼將會回傳:

Access-Control-Allow-Origin: http://foo.example

如此一來,來源並非 http://foo.example 網域(由第 10 行請求標頭中的 ORIGIN 標頭確認)便無法以跨站的方式存取資源。Access-Control-Allow-Origin 標頭必須包含請求當中的 Origin 標頭值。

這裡的來源指的是 Server 允許來自何處的 Client,以這個例子來說:

  • localhost:8080 = Server
  • localhost:9000 = Client

所以要允許的是 localhost:9000 的存取,當然,使用【*】也是可以的。

Server 端是如何編寫 CORS 配置?

敬請期待下回,或是搶先看這裡

See Also

2020/07/29

Delphi in Depth DataSnap 網站應用程式全端開發 出版



購書連結:
書本下載連結:PDF
範列下載連結:Github

內容簡介
  讓你從 0 到 1 進入全端開發領域,掌握後端知識同時也學會前端開發訣竅,點開 Web 開發新技能
   
  Delphi 進階框架 -- DataSnap 全方位構築後端知識。
  jQuery EasyUI 前端技術內涵及接近 VCL 元件的操作概念。
  資料庫常用操作。
  涵蓋語法入門,元件介紹等前後端整合實戰應用。

  給需要本書的人:
  ★有接觸過 JavaScript 卻不知道如何開始才能點開後端技能的人
  ★有接觸過 Delphi 但又不想打掉重練的人
  ★老是寫不好 Delphi 程式的人
  ★想運用 DataSnap 開發全端專案的人


本書特色

  1.本書採漸進式開發後台管理系統的全端網站應用程式,讓學習者能馬上做,立即學。
  2.全書程式碼毫無保留的呈現,重點處還會節錄再說明,更加強化理解與記憶內容。
  3.提供關於框架原理的圖說,讓讀者更能聚焦學習重點上。
  4.實作成品能立即應用在實戰中,實現超速學習成果。

專文推薦

    Eden 是我見過對 Delphi 最有愛的工程師,沒有之一。__【Delphi.KTOP 資深版主 Jason Wong】
   
    Eden 的堅持,是目前台灣地區 Delphi 不可或缺的精神,我從這本書裡面看到 Eden 的用心。__【Indy TIdDNSServer、Delphi / Kylix -Indy 網路程式設計作者 張子仁】

    本書從前端的 Javascript 說起,漸近地深入後端 DataSnap 實作,最後將前後端串起來,這樣的佈局,尤其對全端不熟的開發者而言,相信可以很快速的入門與應用 !__【Delphi.KTOP 副站長 蕭沖】

2020/05/08

Preserving State in DataSnap REST Server



DataSnap REST Server 有儲存狀態的能力,這取決在LifeCycle的設定值。

DataSnap REST Server 儲存狀態方式一:LifeCycle

LifeCycle 的【狀態範圍】在TDSServerClass實例(Class Instance);有 Server, Session(Default), Invocation 三種,影響 TDSServerClass 實例(class instance) 的生命週期。

  • Server:WebServer建立後即會建立一個TDSServerClass實例(class instance)。Server關閉時釋放之。(有狀態 = 全域變數)
  • Session:Session建立時建立一個TDSServerClass實例(class instance)。觸發OnDisconnect時會釋放之,以下兩種條件會觸發:
    • TSQLConnection.Close (TCP 長連接。有狀態 = 僅配對該Session)
    • REST Request 請求結束 (HTTP 的短連接,無狀態)
  • Invocation:同Session建立方式,不同之處在 TCP/HTTP Resquest 請求結束後會強制觸發OnDisconnect事件,釋放TDSServerClass實例(class instance)。

比較妙的地方是 LifeCycle 如果沒有正確走到 OnDisconnect 事件時,TDSServerClass 實例會一直佔在記憶體中無法被釋放,造成 Memory Leak 現象。
然而使用 REST Client (HTTP Request) 卻不會出現此問題 。

也就是使用TSQLConnection為主的場合,WebServer 要定時重新啟動,以釋出那些【潛在異常】的TDSServerClass 實例。

TSQLConnection有LifeCycle可以儲存狀態,而REST Client(含 JavaScript Ajax)卻看得到吃不到,就沒有儲存狀態的方式嗎?



古書曾說:上帝關了一扇窗,必定會開一道門 (並沒有)


DataSnap REST Server 儲存狀態方式二:Session Management

DataSnap REST Server 有個標準配備:Session Management。可以透過它來做到對應 Session 級的狀態管理。

TDSSession.HasData/PutData/GetData 是基本配備,可以自定Key-Value的方式來管理 Session Data。

而在 XE2 之後更提供了 HasObject/PutObject/GetObject,值得一提的是 GetObject 回傳的是物件定址,在開發上可以減少約20%傳值的程式碼!

但也因為 Session Management 的關係,間接提高增設平衡負載(Load balance)的難度。

結語

瞭解datasnap框架兩種狀態管理的邊界範圍與生命週期,框架內建的session management看似會成為【有狀態的服務】而造成效能瓶頸。

事實上LifeCycle為Session和Invocation時,REST Client都不會讓DataSnap保存TDSServerClass實例,那麼也可以讓每一次request產出新的session ID,至於狀態則可存在method參數裡面,少了對session management的依賴,就能達成真正的無狀態,要做平衡負載也是一件簡單的事情了!

SessionID也可以在每次服務結束就釋放,或減少session timeout的時間,以降低多人連線時session management記憶體負載。

同場加映 FormatResult in JSON Method Result

DataSnap REST Server 裡所有的 Method 回傳的格式都會帶一個屬性為【result】的物件 (例:{"result":[0]})。

在不改動 Delphi RTL Source 下也是可以完成自定義工作。

以 EchoString 為例:


就是這麼簡單,也不用再管 Result 要回傳什麼了!


以上資訊分享給大家。我們下次見。

See also

LifeCycle

FormatResult

2020/05/06

JavaScript ES6 call DataSnap API with Promise Fetch

在【Async callback in JS DataSnap Framework】裡有提到可以使用【Handling the Result】,也就是Callback function,如此就能避免掉在XMLHttpRequest中已被棄用的【同步請求(Synchronous request)】。

不過呢,有一好就沒有二好,寫著寫著,我的程式碼就變得和下圖一樣:
傳說中的回呼地獄(Callback hell)
圖:取自網路

聽說JavaScript新標準ES6裡的Promise,它的出現就是為了解決Callback hell而發展出的解決方案。

XHRHttpRequest正常流程

JavaScript DataSnap framework 會自動產出 ServerFunction.js 檔,以【ReverseString】為例,程式碼部分片段如下:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  /*
   * @param Value [in] - Type on server: string
   * @return result - Type on server: string
   */
  this.ReverseString = function(Value) {
    var returnObject = this.executor.executeMethod('ReverseString', "GET", [Value], arguments[1], true, arguments[2], arguments[3]);
    if (arguments[1] == null) {
      if (returnObject != null && returnObject.result != null && isArray(returnObject.result)) {
        var resultArray = returnObject.result;
        var resultObject = new Object();
        resultObject.Value = Value;
        resultObject.result = resultArray[0];
        return resultObject;
      }
      return returnObject;
    }
  };

按照範例來看,最終的resultObject會有【Value】和【result】兩個屬性,分別儲存【傳出值】和【回傳值】,官方範例裡的ServerFunctionInvoker.html,會將這兩個屬性一起展示以方便我們理解。

程式碼和運行結果會是:

1
2
3
4
5
6
7
8
function onReverseStringClick()
{
  var valueField = document.getElementById('valueField');
  var s = serverMethods().ReverseString(valueField.value);
  console.log(`Send Value is : ${s.Value}; Result data is : ${s.result}`);
  valueField.value = s.result;
}
// Send Value is : A B C; Result data is : C B A

可以看出回傳的物件有Value和result兩個屬性
可以看出回傳的物件有Value和result兩個屬性

理想終究是理想,太神化了


就在Server斷線再開時,就遭遇到傳說中的【undefined】:
不僅回傳值沒有,就連輸入值也沒有

因為回傳的沒有【result】,自然也就不會走建立屬性流程
回傳結果是一個例外內容,在範例中沒提到,只有遇到才會知道。(拭淚)

XHRHttpRequest小結

官方操作API的範例在正常流程下沒有問題,而且相當好理解,只是同步應用的方式已被列為棄用,應當避免再使用同步AJAX。
由於【403 SessionExpired】是開發DataSnap服務時非常容易遇到的錯誤,所以使用【Promise fetch】時,除了正常流程外,例外流程也務必要設計進去。


Promise fetch 開發前

官方有寫出完整且詳盡的XHRHttpRequest設計內容,這可能是為了相容於IE系列所寫出的。行動平台的瀏覽器大多有支援最新的JavaScript標準,故直上fetch是沒有問題的。

在操作fetch之前,先來看看XHRHttpRequest發出API請求時的詳細內容:
關鍵在於【Authorization】和【Pragma】這兩個Header參數:

Authorization:驗證方式【Basic】和BASE64化的帳號密碼
Pragma:傳送SessionID,可以透過它在DataSnap Server裡進行狀態管理,Server關閉時,Session Manager(TDSSessionManager)也會被清空,這點要留意!

得知要注意的地方後,就可以使用fetch來如法泡製。

fetch基本用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
"use strict"

function fetchAsyncReverseString() {
  let valueField = document.getElementById("valueFieldAsync");
  fetch(`http://localhost:8080/datasnap/rest/TServerMethods1/ReverseString/${valueField.value}`,
    {
      method:'GET',
      headers:{
        'Accept':'application/json',
        'Content-Type':'text/plain;charset=UTF-8',
        'Authorization':'Basic Og==',
        'pragma':`dssession=${getSessionID()}`
      }
    }
  )
  .then(
    function (response) {
      if (!response.ok)
        throw Error(response.statusText)
      return response.json()
    }
  )
  .then(
    function (returnObject) {
      let dataResult=''

      if (returnObject !== null && returnObject.result !== null && isArray(returnObject.result)) {
        dataResult = returnObject.result[0]
      }

      valueField.value = dataResult
    }
  )
  .catch(
    error => console.error(error)
  )
}

Promise只需要使用【then】和【catch】就能完成所有工作,也就不會有Callback hell的悲劇發生。

瀏覽器記錄的fetch Request內容:
可以看到操作fetch和xhr分別發送request


fetch header內容也可以和xhr相同


錯誤訊息也可以被正確取出
因為fetch就是要從零開始刻,所以程式碼很長是必然的。
有了官方XHRHttpRequest範例的經典,fetch也就可以順利的設計出來。

同步傳輸影響到UX

Promise的核心概念是非同步傳輸,在Facebook中的動態訊息也是用到大量非同步傳輸,如果要等動態訊息全部載入再顯示,那使用者可能會等到抓狂。
但如果是編輯資料時先顥示輸入框一陣子,等資料下載完成後再填入到輸入框給使用者修改,那使用者會為了系統給出空白資料而感到困惑。


所以有必要另外寫一支同步傳輸的函式。

同步傳輸?那不是已被棄用了嗎?

XHRHttpRequest同步傳輸雖然被棄用,但上帝關了一扇窗,必定會開一道門















並沒有。











在fetch中,可以用另一種方式達到類似同步傳輸效果:await。

**await故名思義是【等待】之義,它會等待(async)Promise函式完成後返回【解決 ( resolve )】 或【出錯 ( reject )】 後才會進行下一步,故不完全是【同步】之義。

改寫同步傳輸函式如下:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
"use strict"

async function fetchSyncReverseString() {
  let valueField = document.getElementById("valueFieldSync");
  const response = await fetch(`http://localhost:8080/datasnap/rest/TServerMethods1/ReverseString/${valueField.value}`,
    {
      method:'GET',
      headers:{
        'Accept':'application/json',
        'Content-Type':'text/plain;charset=UTF-8',
        'Authorization':'Basic Og==',
        'pragma':`dssession=${getSessionID()}`
      }
    }
  )
  if (!response.ok)
    throw Error(response.statusText)

  let returnObject = await response.json()

  let dataResult=''

  if (returnObject !== null && returnObject.result !== null && isArray(returnObject.result)) {
    dataResult = returnObject.result[0]
  }
  valueField.value = dataResult;
}


使用新標準的Promise fetch就能滿足同步/非同步兩種要求。

以上資訊提供給各位參考,謝謝各位收看,我們下次見。



***下回預告:DataSnap 狀態管理初探。***

See also

2020/03/12

Delphi Easy Excel 基礎類別設計


Excel是許多從事行政人員必用且愛用的工具之一,所以在程式開發上最常遇到的就是客戶拿產品來和Excel做比較。


這能比嗎!(怒吼)


忠孝不能兩全的情況下,拿Excel檔案進行匯入似乎是很常見的折衷方案。

Delphi號稱VB Killer,操作個Excel自然是基本款,來看看Delphi準備了哪些方案?

Microsoft Office 2000/XP Sample Automation Server Wrapper Components

Microsoft Office 2000/XP Sample Automation Server Wrapper Components


Delphi很貼心的提供Office元件,自Delphi 7~10.3以來,也就這麼兩套。(2020.03.19更新:XE5之後有單獨提供 2010 套件,但只要 Office 更新也是會有相容性問題。延伸閱讀:Delphi XE5 Office 2013 组件更新)

Office 至目前為止已經2019了,許多使用者都開始使用XLSX新格式,這個元件很早就出現相容性不足的問題。

另一個類似元件的做法是使用Import ActiveX Control,匯入新版Excel ActiveX控制項,實務上還蠻類似元件的操作方式,只是問題在Excel升級後仍有可能會有相容性問題產生。

3rd party

三方元件最好的地方在於使用者電腦並不需要安裝Excel即可進行XLS檔案操作,速度和品質都非常好,我用過的有:

NikaSoft NativeExcel

NativeExcel設計上非常貼近OLE的操作方式,在轉換上十分方便,操作XLS檔效率也非常棒,體驗後就離不開它。

但NativeExcel現在已經沒有維護,對XLSX相容性問題也開始浮現。

無奈之餘,還是只能忍痛放棄它。

XLSReadWriteII


這套是資深Delphier Jason Wong 愛用的Excel元件,而且仍有維護,據稱其優點和NatvieExcel相同,但設計概念非OLE操作,所以沒有深入了解。


看來看去,似乎只能回歸OLE設計方式才能達到最高相容的可能性。

畢竟穩定比效能更加重要!

使用OLE開發


使用官方元件很像是使用全域變數的概念,如果OLE也這樣設計如何?


var FExcelApp: Variant;

function ExcelApp: Variant;
  function IsExcelInstalled: Boolean;
  var
    ClassID: TCLSID;
    strOLEObject: string;
  begin
    strOLEObject := 'Excel.Application';
    Result := (CLSIDFromProgID(PWideChar(WideString(strOLEObject)), ClassID) = S_OK);
  end;
begin
  if IsExcelInstalled then
  begin
    if VarIsEmpty(FExcelApp) then
      FExcelApp := CreateOleObject('Excel.Application');
    Result := FExcelApp;
  end
  else
    WinMsgBox.WinError('Not found Excel, call EdenWu, please.');
end;

procedure CloseExcelApp;
begin
  if not VarIsEmpty(FExcelApp) then
  begin
    FExcelApp.ActiveWorkBook.Saved:= 1;
    FExcelApp.DisplayAlerts:= 0;
    FExcelApp.ActiveWorkBook.Close(SaveChanges:=0);
    FExcelApp.Quit;
    VarClear(FExcelApp);
    FExcelApp:=Unassigned;
  end;
end;

initialization
  //FExcelApp := unassinged; // this should not be necessary
finalization
  CloseExcelApp;

把Excel Application視為全域變數,隨時要使用Excel Application都沒問題,非常方便。

開始要寫一些關於Worksheet相關的處理了。咦?

好像還不錯,但不夠OOP,還要更多的OOP

寫著寫著,全域變數和全域函式跑來跑去,程式碼看起來就不夠簡潔了,如果使用OOP設計方式會不會更好呢?

自己刻好像不錯,只是有個臨摹的對象更好,來看看有沒有好範本可以參考。

Github搜尋一陣後,發現有高手寫了個SimpleExcel,仔細看了下程式碼,嗯,很OO,這我可以。

這樣的程式碼要自己來寫到底要花多少時間呢?
如果站在巨人的肩膀上會不會省下更多時間做更重要的事呢?

一邊使用SimpleExcel的同時,一邊思考著。


-全文完-

See also

2020/03/04

外掛小幫手FastReport

原來是報表工具啊,我還以為是外掛開發程式呢! (笑)


Delphi最有趣的地方是它有很多三方元件可以支撐開發專案,以上圖為例,功能不多,通常列為小專案,這時使用諾大的Delphi開發專案顯得太小題大作。


這時FastReport就派上用場,FastReport雖然是報表工具,別小看它,它搭載的Report Designer備有Pascal直譯功能,還有內建VCL基礎元件,有效利用下,可以大幅減少小專案開發時間,還能降低Delphi升級所帶來的改版衝擊呢!


報表工具當然要著重在報表的設計,Report Designer提供的函式庫想當然是不夠使用。FastReport也明白這點,便在FR Document也寫了很多的輔助說明,在藉助FR Document的神奇力量後,我也添加了許多常用函式到Report Desinger中,開發起來便利度提高非常多!


除了可視元件外,像是常常用到的TStringList基礎類別也有提供,程式寫起來超安心。


還有ADO, DBX等資料庫元件,用了Report Designer,很多小型專案都在移轉到FR後就刪除,專案存放目錄差點手殘到刪光光!


和Delphi一樣可以下中斷點,這Debug很可以。

一想到Delphi要開個幾十秒,開啟FastReport Report Designer不僅快,刻起程式也是趣味十足,提供各位參考。


See also

購買連結:Fast-Report官方網站