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.
634 lines
20 KiB
634 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
|
|
|
|
}
|
|
|
|
}
|
|
;
|