From d26056daaffd4f9c7c7ec19c614b1244fbd2c8be Mon Sep 17 00:00:00 2001 From: Elivo Date: Fri, 8 Aug 2025 18:09:15 +0800 Subject: [PATCH] =?UTF-8?q?StaticController=20=E5=A2=9E=E5=8A=A0=20Web.zip?= =?UTF-8?q?=20=E7=9A=84=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Apewer/Web/StaticController.cs | 493 ++++++++++++++++++++++++--------- 1 file changed, 360 insertions(+), 133 deletions(-) diff --git a/Apewer/Web/StaticController.cs b/Apewer/Web/StaticController.cs index 6733cb2..1b0068c 100644 --- a/Apewer/Web/StaticController.cs +++ b/Apewer/Web/StaticController.cs @@ -10,61 +10,74 @@ namespace Apewer.Web public class StaticController : ApiController { - /// 允许服务器端包含(Server Side Include)。 - /// 默认值:允许。 - protected bool AllowSSI { get; set; } = true; - - /// 当执行目录且没有默认文档时,枚举子目录和子文件。 - /// 默认值:不允许。 - protected bool AllowEnumerate { get; set; } = false; - - /// 获取已解析的站点根目录。 - protected string Root { get => _root?.Value; } - - List PathSegments; + #region 初始化 + Func _initializer = null; Class _root = null; /// public StaticController() : base((c) => { ((StaticController)c).Initialize(); return false; }) { } + /// + public StaticController(Func initializer) : base((c) => { ((StaticController)c).Initialize(); return false; }) + { + _initializer = initializer; + } + void Initialize() { if (Request == null || Request.Url == null) return; - var absolute = Request.Url.AbsolutePath; - var split = absolute == null ? new string[0] : absolute.Split('/', '\\'); - PathSegments = new List(split.Length); - foreach (var item in split) + + if (_initializer != null) { - var trim = TextUtility.Trim(TextUtility.DecodeUrl(item)); - if (string.IsNullOrEmpty(trim)) continue; - if (trim == "." || trim == "..") continue; - PathSegments.Add(trim); + var jump = _initializer.Invoke(this); + if (jump == JumpStatement.Break) return; } - absolute = TextUtility.Join("/", PathSegments); - - var path = MapPath(absolute); - if (PathSegments.Count < 1) Directory(GetRoot()); - else if (IsBlocked(PathSegments[0])) Respond404(path); - else if (StorageUtility.FileExists(path)) File(path); - else if (StorageUtility.DirectoryExists(path)) Directory(path); - else Respond404(path); - } - #region virtual + // 获取相对路径 + var urlPath = GetUrlPath(); + if (string.IsNullOrEmpty(urlPath)) return; - /// 响应 404 状态。 - /// 默认:设置状态为 404,不输出内容。 - protected virtual void Respond404(string path) - { - Response.Model = new ApiStatusModel(404); + // 内置的处理程序 + ApiUtility.StopReturn(Response); + Execute(urlPath); } - /// 响应 403 状态。 - /// 默认:设置状态为 403,不输出内容。 - protected virtual void Respond403(string path) + #endregion + + #region 自定义 + + /// 允许服务器端包含(Server Side Include)。 + /// 默认值:允许。 + protected bool AllowSSI { get; set; } = true; + + /// 允许 GetRoot 方法访问应用程序目录。 + /// 默认值:不允许。 + protected bool AllowApplicationPath { get; set; } = false; + + /// 获取已解析的站点根目录。 + protected string Root { get => _root?.Value; } + + /// 执行路径。 + protected virtual void Execute(string urlPath) { - Response.Model = new ApiStatusModel(403); + // 自定义处理 + var model = GetModel(urlPath); + if (model != null) + { + Response.Model = model; + return; + } + + // 自定义处理 + var result = GetResult(urlPath); + if (result != null) + { + Response.Model = result; + return; + } + + InternalExecute(urlPath); } /// 获取此静态站点的目录。 @@ -111,156 +124,365 @@ namespace Apewer.Web return @static; } - _root = new Class(app); - return app; + if (AllowApplicationPath) + { + _root = new Class(app); + return app; + } + else + { + _root = new Class(null); + return null; + } + } + + /// 获取 URL 路径。 + protected virtual string GetUrlPath() => Request.Url.AbsolutePath; + + /// 获取响应结果。 + protected virtual IActionResult GetResult(string urlPath) => null; + + /// 获取响应结果。 + protected virtual ApiModel GetModel(string urlPath) => null; + + /// 加载 Web.zip 文件。 + protected virtual Dictionary 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; } /// 从扩展名获取内容类型。 protected virtual string ContentType(string extension) => NetworkUtility.Mime(extension); - /// 从扩展名和文件路径获取过期时间。 - /// 默认值:0,不缓存。 - protected virtual int Expires(string extension, string path) => 0; + #endregion + + #region 开放方法 + + /// 响应重定向。 + protected virtual void RespondRedirect(string urlPath, string location) => Response.Model = new ApiRedirectModel(location); + + /// 响应 400 状态。 + /// 默认:设置状态为 400,不输出内容。 + protected virtual void Respond400(string path, string reason) => Response.Model = new ApiStatusModel(400); + + /// 响应 404 状态。 + /// 默认:设置状态为 404,不输出内容。 + protected virtual void Respond404(string urlPath) => Response.Model = new ApiStatusModel(404); + + /// 响应 403 状态。 + /// 默认:设置状态为 403,不输出内容。 + 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(); - /// 已解析到本地文本路径,执行此路径。 - /// 默认:输出文件内容,文件不存在时输出 404 状态。 - protected virtual void File(string path) + // Zip 文件的缓存 + static Dictionary> WebZips = new Dictionary>(); + + // 解析 URL 的路径,获取本地路径。 + string MapPath(string urlPath) { - if (!System.IO.File.Exists(path)) + var path = GetRoot(); + if (path.IsEmpty()) return ""; + if (string.IsNullOrEmpty(urlPath)) return path; + + foreach (var split in urlPath.Split('/', '\\')) { - Respond404(path); + 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 ext = Path.GetExtension(path).Lower(); - if (ext.Length > 1 && ext.StartsWith(".")) ext = ext.Substring(1); + // 检查路径 + var urlIsDir = urlPath.EndsWith("/") || urlPath.EndsWith("\\"); + var urlExt = null as string; + var urlIsHtml = false; + var segments = new List(); + { + 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); - // 按扩展名获取缓存过期时间。 - var expires = Expires(ext, path); + // 本地文件 + if (StorageUtility.FileExists(storagePath)) + { + ExecuteFile(storagePath, urlPath, urlExt, urlIsHtml); + return; + } - // 按扩展名获取 Content-Type。 - var type = ContentType(ext); - if (string.IsNullOrEmpty(type)) type = NetworkUtility.Mime(ext); + // 目录 + 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; + } + } + } - // Server Side Includes - if (AllowSSI && ext == "html" || ext == "htm" || ext == "shtml") + // WebZip + var webZip = GetWebZip(); + if (webZip != null) { - var html = ReadWithSSI(path); - var bytes = html.Bytes(); + // 文件 + { + 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; + } + } + } - var model = new ApiBytesModel(); - if (expires > 0) model.Expires = expires; - model.ContentType = type; - model.Bytes = bytes; + // 未知 + 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 { - var stream = StorageUtility.OpenFile(path, true); - - var model = new ApiStreamModel(); - if (expires > 0) model.Expires = expires; - model.ContentType = type; - model.AutoDispose = true; - model.Stream = stream; + 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; } } - /// 已解析到本地目录路径,执行此路径。 - /// 默认:输出文件内容,文件不存在时输出 404 状态。 - protected virtual void Directory(string path) + /// 读取文本文件。 + string ReadTextFile(string urlPath, int recursive) { - if (!System.IO.Directory.Exists(path)) + // 主文件保留原有的 BOM + var wipeBom = recursive > 0; + + // 本地文件 + var path = MapPath(urlPath); + if (StorageUtility.FileExists(path)) { - Respond404(path); - return; + var data = StorageUtility.ReadFile(path, wipeBom); + var text = data.Text(); + text = ServerSideIncludes(text, recursive); + return text; } - var @default = Default(path); - if (!string.IsNullOrEmpty(@default)) + // Zip + var webZip = GetWebZip(); + if (webZip != null) { - File(@default); - return; + if (webZip.TryGetValue(urlPath, out var data)) + { + var text = data.Text(); + text = ServerSideIncludes(text, recursive); + return text; + } } - if (AllowEnumerate) Response.Data = ListChildren(path); - else Respond403(path); + // 文件不存在 + return null; } /// 在指定目录下搜索默认文件。 /// 完整文件路径,当搜索失败时返回 NULL。 - protected string Default(string directory) + string SearchDefaultFile(string directoryPath) { - var subs = StorageUtility.GetSubFiles(directory); - if (subs.Count < 0) return null; - subs.Sort(); - - var names = new Dictionary(subs.Count); - foreach (var sub in subs) + // 获取子文件路径 + var filePaths = StorageUtility.GetSubFiles(directoryPath); + if (filePaths.Count < 0) return null; + filePaths.Sort(); + + // 获取文件名 + var dict = new Dictionary(filePaths.Count); + foreach (var filePath in filePaths) { - var name = Path.GetFileName(sub); - if (names.ContainsKey(name)) continue; - names.Add(name, sub); + var name = Path.GetFileName(filePath).ToLower(); + if (dict.ContainsKey(name)) continue; + dict.Add(name, filePath); } - foreach (var sub in subs) + + // 按顺序获取 + foreach (var defaultFileName in DefaultFileNames) { - var name = Path.GetFileName(sub); - var lower = name.ToLower(); - if (lower == name) continue; - if (names.ContainsKey(lower)) continue; - names.Add(lower, sub); + if (dict.TryGetValue("index.html", out var physicalPath)) + { + return physicalPath; + } } - if (names.ContainsKey("index.html")) return names["index.html"]; - if (names.ContainsKey("index.htm")) return names["index.htm"]; - if (names.ContainsKey("default.html")) return names["default.html"]; - if (names.ContainsKey("default.htm")) return names["default.htm"]; return null; } - #endregion - - #region private - - // 解析 URL 的路径,获取本地路径。 - string MapPath(string urlPath) + Dictionary GetWebZip() { - var path = GetRoot(); - if (!string.IsNullOrEmpty(urlPath)) + var instanceType = GetType(); + lock (WebZips) { - foreach (var split in urlPath.Split('/')) + if (WebZips.TryGetValue(instanceType, out var webZip)) + { + return webZip; + } + else { - var seg = split.ToTrim(); - if (string.IsNullOrEmpty(seg)) continue; - if (seg == "." || seg == "..") continue; - path = StorageUtility.CombinePath(path, seg); + var dict = LoadWebZip(); + if (dict != null) + { + var temp = new Dictionary(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 path; + return null; } - // Server Side Includes - string ReadWithSSI(string path, int recursive = 0) + string ServerSideIncludes(string text, int recursive) { - if (recursive > 10) return ""; - - var input = StorageUtility.ReadFile(path, true); - if (input == null || input.LongLength < 1) return ""; - - // 尝试解码。 - var html = TextUtility.FromBytes(input); - if (string.IsNullOrEmpty(html)) return ""; + if (!AllowSSI) return text; + if (recursive > 10) return text; + if (string.IsNullOrEmpty(text)) return ""; // 按首尾截取。 const string left = ""; const string head = "#include virtual="; var sb = new StringBuilder(); - var text = html; while (true) { var offset = text.IndexOf(left); @@ -288,9 +510,9 @@ namespace Apewer.Web { temp = temp.Substring(head.Length); temp = temp.Replace("\"", ""); - var subPath = MapPath(temp); - var subText = ReadWithSSI(subPath, recursive + 1); - if (subText != null && subText.Length > 0) sb.Append(subText); + + var includeText = ReadTextFile(temp, recursive + 1); + if (includeText != null && includeText.Length > 0) sb.Append(includeText); } else { @@ -305,6 +527,10 @@ namespace Apewer.Web return output; } + #endregion + + #region private + /// 列出指定目录的子项。 Json ListChildren(string directory) { @@ -363,7 +589,8 @@ namespace Apewer.Web return array; } - static bool IsBlocked(string segment) + /// 请求筛选:检查段内容是要被屏蔽。 + bool IsBlocked(string segment) { if (string.IsNullOrEmpty(segment)) return true; var lower = segment.ToLower();