寫 JavaScript 在別人家的網頁跑,若想將產生結果存成本機檔案,有一些做法,例如:產生一個 href 為 Data URI 的連結供點選下載(範例:用 100 行實現 HTML5 可存檔塗鴉版)、靠伺服器端程式實現自動下載 (範例:Canvas.toDataURL()另存檔案)... 等等。但想要做到不知不覺將檔案寫到本機的指定資料夾,基於安全考量,通常會被瀏覽器禁止。

偏偏我有個 Side-Project 有這種需求,我想在某個監看網頁上用 setInterval 跑一段 JavaScript 定期排程,當滿足某個條件時就擷圖備查。除了下載這條路,倒是可以寫個輕薄短小的 WebAPI 在本機跑,讓 JavaScript 透過 AJAX 上傳圖檔。但這種跨來源呼叫,必須要克服 CORS 限制,回應時得加入 Access-Control-Allow-Origin Header 才會成功。

想了一下,不如就用 ASP.NET Core 6 寫寫看吧! 我還給自己加了一個額外挑戰 - 用 dotnet new web 空白網站樣版,全部邏輯寫在 Program.cs 搞定。

先看成果,在高公局 1968 網頁執行以下程式,產生一個 .txt 跟用 Canvas 繪圖轉 .png 上傳到 ASP.NET Core 網站存檔:

function sendBlob(url, blob) {
    var xhr = new XMLHttpRequest();
    xhr.open("POST", url, true);
    xhr.onload = function (oEvent) {
        console.log(xhr.responseText)
    };
    xhr.send(blob);
}

sendBlob('https://localhost:7008/?f=test.txt', new Blob(['ASP.NET Core Rocks!'], {type: 'text/plain'}));

var canvas = document.createElement('canvas');
canvas.width = 100;
canvas.height = 100;
var ctx = canvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'blue';
ctx.beginPath();
ctx.moveTo(50,0);
ctx.lineTo(0,100);
ctx.lineTo(100,100);
ctx.fill();
var blob = new Blob([canvas.toDataURL().split(',')[1]], { type: 'text/plain' });
sendBlob('https://localhost:7008/?f=test.png&t=base64', blob);

檔案成功寫入到本機 D:\FileStore

接著來看 ASP.NET Core 端程式,多虧 ASP.NET Core 6 功能完整,內建 CORS 支援,要從 appsettings.json 讀取檔案也很方便,完全不需參照第三方程式庫,整個專案只用了一個 Program.cs 就搞定,還不到 50 行。

var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration; // 取得 IConfiguration
// 取得檔案儲存位置
var fileStorePath = config.GetValue<string>("FileStorePath");
Directory.CreateDirectory(fileStorePath);
// 取得允許的副檔名
var allowExts = config.GetSection("AllowExts").Get<string[]>();
// 加入 CORS 服務
const string OriginsFromSetting = "OriginsFromAppSettingsJson";
builder.Services.AddCors(options => {
    options.AddPolicy(
        name: OriginsFromSetting,
        builder => {
            builder.WithOrigins(
                // 轉 string[] 需要 Microsoft.Extensions.Configuration.Binder
                config.GetSection("AllowOrigins").Get<string[]>());
        }
    );
});
var app = builder.Build();
// 啟用 CORS Middleware
app.UseCors();
app.MapGet("/", () => "Hello World!");
app.MapPost("/", async (context) => {
    var fileName = context.Request.Query["f"].FirstOrDefault();
    var res = "Unknown";
    if (string.IsNullOrEmpty(fileName) || 
        fileName.IndexOfAny(Path.GetInvalidPathChars()) > -1) 
        res = "Invalid filename";
    else if (!allowExts.Contains(Path.GetExtension(fileName))) 
        res = "Invalid file type";
    else {
        // 只保留檔名(去除可能夾帶的路徑)加上時間序號
        fileName = DateTime.Now.ToString("yyyyMMdd-HHmmss-") + Path.GetFileName(fileName);
        using (var ms = new MemoryStream()) 
        {
            await context.Request.Body.CopyToAsync(ms);
            var filePath = Path.Combine(fileStorePath, fileName);
            var data = ms.ToArray();
            if (context.Request.Query["t"].FirstOrDefault() == "base64") {
                data = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(data));
            }
            File.WriteAllBytes(filePath, data);
            res = "OK";
        }
    }
    await context.Response.WriteAsync(res);
}).RequireCors(OriginsFromSetting);
app.Run();

雖然是自用且在本機執行,我還是加了基本安全控管。檔名部分用 Path.GetFileName() 過濾路徑防止夾帶 ..\ 進行路徑操控;用 fileName.IndexOfAny(Path.GetInvalidPathChars()) 檢查無效字元;Access-Control-Allow-Origin 不使用萬用字元,採正向表列由設定檔讀取;上傳副檔名也以白名單限制,以免被上傳 exe 等可執行檔(雖然在本機自用發生機率趨零,但就養成好習慣唄)。

appsettings.json 範例如下:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "AllowOrigins": [
    "https://1968.freeway.gov.tw",
    "https://blog.darkthread.net"
  ],
  "AllowExts": [
    ".txt", ".png"
  ],
  "FileStorePath": "D:\\FileStore"
}

又完成一個輕巧小品,以後要蒐集 JavaScript 小程式回拋結果就方便多了。

A handy CORS upload service writing with ASP.NET Core in 50 lines Program.cs.


Comments

# by Joker

讚讚,最近也在玩minimal API 真的是很輕便,怎麼寫怎麼活。

# by 小黑

神~

Post a comment