You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

633 lines
20 KiB

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace Apewer.Web
{
/// <summary>静态站点控制器。</summary>
public class StaticController : ApiController
{
#region 初始化
Func<StaticController, JumpStatement> _initializer = null;
Class<string> _root = null;
/// <summary></summary>
public StaticController() : base((c) => { ((StaticController)c).Initialize(); return false; }) { }
/// <summary></summary>
public StaticController(Func<StaticController, JumpStatement> initializer) : base((c) => { ((StaticController)c).Initialize(); return false; })
{
_initializer = initializer;
}
void Initialize()
{
if (Request == null || Request.Url == null) return;
if (_initializer != null)
{
var jump = _initializer.Invoke(this);
if (jump == JumpStatement.Break) return;
}
// 获取相对路径
var urlPath = GetUrlPath();
if (string.IsNullOrEmpty(urlPath)) return;
// 内置的处理程序
ApiUtility.StopReturn(Response);
Execute(urlPath);
}
#endregion
#region 自定义
/// <summary>允许服务器端包含(Server Side Include)。</summary>
/// <remarks>默认值:允许。</remarks>
protected bool AllowSSI { get; set; } = true;
/// <summary>允许 GetRoot 方法访问应用程序目录。</summary>
/// <remarks>默认值:不允许。</remarks>
protected bool AllowApplicationPath { get; set; } = false;
/// <summary>获取已解析的站点根目录。</summary>
protected string Root { get => _root?.Value; }
/// <summary>执行路径。</summary>
protected virtual void Execute(string urlPath)
{
// 自定义处理
var model = GetModel(urlPath);
if (model != null)
{
Response.Model = model;
return;
}
// 自定义处理
var result = GetResult(urlPath);
if (result != null)
{
Response.Model = result;
return;
}
InternalExecute(urlPath);
}
/// <summary>获取此静态站点的目录。</summary>
protected virtual string GetRoot()
{
if (_root) return _root.Value;
var app = RuntimeUtility.ApplicationPath;
var paths = StorageUtility.GetSubFiles(app);
foreach (var path in paths)
{
var split = path.Split('/', '\\');
var lower = split[split.Length - 1];
switch (lower)
{
case "index.html":
case "index.htm":
case "default.html":
case "default.htm":
case "favicon.ico":
_root = new Class<string>(app);
return app;
}
}
var www = StorageUtility.CombinePath(app, "www");
if (System.IO.Directory.Exists(www))
{
_root = new Class<string>(www);
return www;
}
var web = StorageUtility.CombinePath(app, "web");
if (System.IO.Directory.Exists(web))
{
_root = new Class<string>(web);
return web;
}
var @static = StorageUtility.CombinePath(app, "static");
if (System.IO.Directory.Exists(@static))
{
_root = new Class<string>(@static);
return @static;
}
if (AllowApplicationPath)
{
_root = new Class<string>(app);
return app;
}
else
{
_root = new Class<string>(null);
return null;
}
}
/// <summary>获取 URL 路径。</summary>
protected virtual string GetUrlPath() => Request.Url.AbsolutePath;
/// <summary>获取响应结果。</summary>
protected virtual IActionResult GetResult(string urlPath) => null;
/// <summary>获取响应结果。</summary>
protected virtual ApiModel GetModel(string urlPath) => null;
/// <summary>加载 Web.zip 文件。</summary>
protected virtual Dictionary<string, byte[]> LoadWebZip()
{
var names = new string[] { "Web.zip", "web.zip" };
foreach (var name in names)
{
var path = StorageUtility.CombinePath(RuntimeUtility.ApplicationPath, name);
if (StorageUtility.FileExists(path))
{
var data = StorageUtility.ReadFile(path);
var dict = BytesUtility.FromZip(data);
return dict;
}
}
return null;
}
/// <summary>从扩展名获取内容类型。</summary>
protected virtual string ContentType(string extension) => NetworkUtility.Mime(extension);
#endregion
#region 开放方法
/// <summary>响应重定向。</summary>
protected virtual void RespondRedirect(string urlPath, string location) => Response.Model = new ApiRedirectModel(location);
/// <summary>响应 400 状态。</summary>
/// <remarks>默认:设置状态为 400,不输出内容。</remarks>
protected virtual void Respond400(string path, string reason) => Response.Model = new ApiStatusModel(400);
/// <summary>响应 404 状态。</summary>
/// <remarks>默认:设置状态为 404,不输出内容。</remarks>
protected virtual void Respond404(string urlPath) => Response.Model = new ApiStatusModel(404);
/// <summary>响应 403 状态。</summary>
/// <remarks>默认:设置状态为 403,不输出内容。</remarks>
protected virtual void Respond403(string urlPath) => Response.Model = new ApiStatusModel(403);
#endregion
#region 内置的执行程序
const bool WipeBom = true;
// 默认文档的文件名
static string[] DefaultFileNames = new string[] { "index.html", "index.htm", "default.html", "default.htm" };
// 文件名中不允许出现的字符
static char[] InvalidFileNameChars = Path.GetInvalidFileNameChars();
// Zip 文件的缓存
static Dictionary<Type, Dictionary<string, byte[]>> WebZips = new Dictionary<Type, Dictionary<string, byte[]>>();
// 解析 URL 的路径,获取本地路径。
string MapPath(string urlPath)
{
var path = GetRoot();
if (path.IsEmpty()) return "";
if (string.IsNullOrEmpty(urlPath)) return path;
foreach (var split in urlPath.Split('/', '\\'))
{
var segment = split.ToTrim();
if (segment == null || segment == "" || segment == "." || segment == "..") continue;
path = StorageUtility.CombinePath(path, segment);
}
return path;
}
void InternalExecute(string urlPath)
{
if (urlPath.IsEmpty())
{
Respond400(urlPath, "请求的 URL 路径无效。");
return;
}
// 检查路径
var urlIsDir = urlPath.EndsWith("/") || urlPath.EndsWith("\\");
var urlExt = null as string;
var urlIsHtml = false;
var segments = new List<string>();
{
var split = urlPath == null ? new string[0] : urlPath.Split('/', '\\');
segments.Capacity = split.Length;
foreach (var item in split)
{
var segment = TextUtility.DecodeUrl(item).Trim();
if (string.IsNullOrEmpty(segment)) continue;
if (segment == "." || segment == "..") continue;
// 检查无效字符
foreach (var c in segment)
{
if (Array.IndexOf(InvalidFileNameChars, c) > -1)
{
Respond400(urlPath, $"请求的路径中含有无效字符。");
return;
}
}
segments.Add(segment);
}
// 重置 UrlPath
urlPath = "/" + segments.Join("/");
if (segments.Count > 0)
{
// 请求筛选
if (IsBlocked(segments[0]))
{
Respond404(urlPath);
return;
}
urlExt = segments[segments.Count - 1].ToLower().Split('.').Last();
urlIsHtml = urlExt == "html" || urlExt == "htm" || urlExt == "shtml";
}
}
// 本地存储路径
var storagePath = MapPath(urlPath);
// 本地文件
if (StorageUtility.FileExists(storagePath))
{
ExecuteFile(storagePath, urlPath, urlExt, urlIsHtml);
return;
}
// 目录
if (StorageUtility.DirectoryExists(storagePath))
{
var filePath = SearchDefaultFile(storagePath);
if (!string.IsNullOrEmpty(filePath))
{
if (urlIsDir)
{
ExecuteFile(filePath, urlPath, urlExt, urlIsHtml);
return;
}
else
{
var location = urlPath + "/";
RespondRedirect(urlPath, location);
return;
}
}
}
// WebZip
var webZip = GetWebZip();
if (webZip != null)
{
// 文件
{
if (webZip.TryGetValue(urlPath, out var data))
{
ExecuteFile(data, urlExt, urlIsHtml);
return;
}
}
// 尝试匹配默认文档
foreach (var defaultName in DefaultFileNames)
{
var defaultPath = TextUtility.AssureEnds(urlPath, "/") + defaultName;
if (webZip.TryGetValue(defaultPath, out var data))
{
if (urlIsDir)
{
ExecuteFile(data, defaultName, true);
}
else
{
var location = TextUtility.AssureEnds(urlPath, "/");
RespondRedirect(urlPath, location);
}
return;
}
}
}
// 未知
Respond404(urlPath);
}
void ExecuteFile(string storagePath, string urlPath, string urlExt, bool urlIsHtml)
{
var contentType = ContentType(urlExt);
if (urlIsHtml)
{
var text = ReadTextFile(urlPath, 0);
var model = new ApiTextModel(text, contentType);
Response.Model = model;
}
else
{
try
{
var stream = StorageUtility.OpenFile(storagePath, true);
var model = new ApiStreamModel();
model.ContentType = contentType;
model.AutoDispose = true;
model.Stream = stream;
Response.Model = model;
}
catch (Exception ex)
{
Respond400(storagePath, $"<{ex.GetType().Name}> {ex.Message}");
}
}
}
void ExecuteFile(byte[] data, string urlExt, bool urlIsHtml)
{
var contentType = ContentType(urlExt);
if (urlIsHtml)
{
var text = data.Text();
text = ServerSideIncludes(text, 0);
var model = new ApiTextModel(text, contentType);
Response.Model = model;
}
else
{
var model = new ApiBytesModel(data, contentType);
Response.Model = model;
}
}
/// <summary>读取文本文件。</summary>
string ReadTextFile(string urlPath, int recursive)
{
// 主文件保留原有的 BOM
var wipeBom = recursive > 0;
// 本地文件
var path = MapPath(urlPath);
if (StorageUtility.FileExists(path))
{
var data = StorageUtility.ReadFile(path, wipeBom);
var text = data.Text();
text = ServerSideIncludes(text, recursive);
return text;
}
// Zip
var webZip = GetWebZip();
if (webZip != null)
{
if (webZip.TryGetValue(urlPath, out var data))
{
var text = data.Text();
text = ServerSideIncludes(text, recursive);
return text;
}
}
// 文件不存在
return null;
}
/// <summary>在指定目录下搜索默认文件。</summary>
/// <returns>完整文件路径,当搜索失败时返回 NULL。</returns>
string SearchDefaultFile(string directoryPath)
{
// 获取子文件路径
var filePaths = StorageUtility.GetSubFiles(directoryPath);
if (filePaths.Count < 0) return null;
filePaths.Sort();
// 获取文件名
var dict = new Dictionary<string, string>(filePaths.Count);
foreach (var filePath in filePaths)
{
var name = Path.GetFileName(filePath).ToLower();
if (dict.ContainsKey(name)) continue;
dict.Add(name, filePath);
}
// 按顺序获取
foreach (var defaultFileName in DefaultFileNames)
{
if (dict.TryGetValue("index.html", out var physicalPath))
{
return physicalPath;
}
}
return null;
}
Dictionary<string, byte[]> GetWebZip()
{
var instanceType = GetType();
lock (WebZips)
{
if (WebZips.TryGetValue(instanceType, out var webZip))
{
return webZip;
}
else
{
var dict = LoadWebZip();
if (dict != null)
{
var temp = new Dictionary<string, byte[]>(dict.Count);
foreach (var kvp in dict)
{
if (kvp.Key.IsEmpty()) continue;
var url = string.Join("/", kvp.Key.Split('/', '\\'));
url = "/" + url.ToLower();
if (temp.ContainsKey(url)) continue;
temp.Add(url, kvp.Value);
}
dict = temp;
}
WebZips.Add(instanceType, dict);
}
}
return null;
}
string ServerSideIncludes(string text, int recursive)
{
if (!AllowSSI) return text;
if (recursive > 10) return text;
if (string.IsNullOrEmpty(text)) return "";
// 按首尾截取。
const string left = "<!--";
const string right = "-->";
const string head = "#include virtual=";
var sb = new StringBuilder();
while (true)
{
var offset = text.IndexOf(left);
if (offset < 0)
{
sb.Append(text);
break;
}
if (offset > 0)
{
sb.Append(text.Substring(0, offset));
text = text.Substring(offset + left.Length);
}
else text = text.Substring(left.Length);
var length = text.IndexOf(right);
if (length < 1)
{
sb.Append(left);
sb.Append(text);
break;
}
var inner = text.Substring(0, length);
var temp = inner.ToTrim();
if (temp.StartsWith(head))
{
temp = temp.Substring(head.Length);
temp = temp.Replace("\"", "");
var includeText = ReadTextFile(temp, recursive + 1);
if (includeText != null && includeText.Length > 0) sb.Append(includeText);
}
else
{
sb.Append(left);
sb.Append(inner);
sb.Append(right);
}
text = text.Substring(length + right.Length);
}
var output = sb.ToString();
return output;
}
#endregion
#region private
/// <summary>列出指定目录的子项。</summary>
Json ListChildren(string directory)
{
if (!System.IO.Directory.Exists(directory)) return null;
var json = Json.NewObject();
json.SetProperty("directories", ListDirectories(directory));
json.SetProperty("files", ListFiles(directory));
return json;
}
Json ListDirectories(string directory)
{
var array = Json.NewArray();
var subs = StorageUtility.GetSubDirectories(directory);
subs.Sort();
foreach (var sub in subs)
{
var split = sub.Split('/', '\\');
var name = split[split.Length - 1];
if (IsBlocked(name)) continue;
var json = Json.NewObject();
json.SetProperty("name", name);
try
{
var info = new DirectoryInfo(sub);
json.SetProperty("modified", info.LastWriteTimeUtc.Stamp());
}
catch { }
array.AddItem(json);
}
return array;
}
Json ListFiles(string directory)
{
var array = Json.NewArray();
var subs = StorageUtility.GetSubFiles(directory);
subs.Sort();
foreach (var sub in subs)
{
var name = Path.GetFileName(sub);
if (IsBlocked(name)) continue;
var json = Json.NewObject();
json.SetProperty("name", name);
try
{
var info = new FileInfo(sub);
json.SetProperty("size", info.Length);
json.SetProperty("modified", info.LastWriteTimeUtc.Stamp());
}
catch { }
array.AddItem(json);
}
return array;
}
/// <summary>请求筛选:检查段内容是要被屏蔽。</summary>
bool IsBlocked(string segment)
{
if (string.IsNullOrEmpty(segment)) return true;
var lower = segment.ToLower();
switch (lower)
{
case ".":
case "..":
// Synology
case "@eadir":
case "#recycle":
// Windows
case "$recycle.bin":
case "recycler":
case "system volume information":
case "desktop.ini":
case "thumbs.db":
// macOS
case ".ds_store":
case ".localized":
// IIS
case "app_code":
case "app_data":
case "aspnet_client":
case "bin":
case "web.config":
return true;
}
return false;
}
#endregion
}
}