using System; using System.Collections.Generic; using System.IO; using System.Text; namespace Apewer.Web { /// <summary>静态站点控制器。</summary> public class StaticController : ApiController { /// <summary>允许服务器端包含(Server Side Include)。</summary> /// <remarks>默认值:允许。</remarks> protected bool AllowSSI { get; set; } = true; /// <summary>当执行目录且没有默认文档时,枚举子目录和子文件。</summary> /// <remarks>默认值:不允许。</remarks> protected bool AllowEnumerate { get; set; } = false; /// <summary>获取已解析的站点根目录。</summary> protected string Root { get => _root?.Value; } List<string> PathSegments; Class<string> _root = null; /// <summary></summary> public StaticController() : base((c) => { ((StaticController)c).Initialize(); return false; }) { } 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<string>(split.Length); foreach (var item in split) { var trim = TextUtility.Trim(TextUtility.DecodeUrl(item)); if (string.IsNullOrEmpty(trim)) continue; if (trim == "." || trim == "..") continue; PathSegments.Add(trim); } 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 /// <summary>响应 404 状态。</summary> /// <remarks>默认:设置状态为 404,不输出内容。</remarks> protected virtual void Respond404(string path) { Response.Model = new ApiStatusModel(404); } /// <summary>响应 403 状态。</summary> /// <remarks>默认:设置状态为 403,不输出内容。</remarks> protected virtual void Respond403(string path) { Response.Model = new ApiStatusModel(403); } /// <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 www; } var @static = StorageUtility.CombinePath(app, "static"); if (System.IO.Directory.Exists(@static)) { _root = new Class<string>(@static); return www; } _root = new Class<string>(app); return app; } /// <summary>从扩展名获取内容类型。</summary> protected virtual string ContentType(string extension) => NetworkUtility.Mime(extension); /// <summary>从扩展名和文件路径获取过期时间。</summary> /// <remarks>默认值:0,不缓存。</remarks> protected virtual int Expires(string extension, string path) => 0; /// <summary>已解析到本地文本路径,执行此路径。</summary> /// <remarks>默认:输出文件内容,文件不存在时输出 404 状态。</remarks> protected virtual void File(string path) { if (!System.IO.File.Exists(path)) { Respond404(path); return; } // 获取文件扩展名。 var ext = Path.GetExtension(path).Lower(); if (ext.Length > 1 && ext.StartsWith(".")) ext = ext.Substring(1); // 按扩展名获取缓存过期时间。 var expires = Expires(ext, path); // 按扩展名获取 Content-Type。 var type = ContentType(ext); if (string.IsNullOrEmpty(type)) type = NetworkUtility.Mime(ext); // Server Side Includes if (AllowSSI && ext == "html" || ext == "htm" || ext == "shtml") { var html = ReadWithSSI(path); var bytes = html.Bytes(); var model = new ApiBytesModel(); if (expires > 0) model.Expires = expires; model.ContentType = type; model.Bytes = bytes; 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; Response.Model = model; } } /// <summary>已解析到本地目录路径,执行此路径。</summary> /// <remarks>默认:输出文件内容,文件不存在时输出 404 状态。</remarks> protected virtual void Directory(string path) { if (!System.IO.Directory.Exists(path)) { Respond404(path); return; } var @default = Default(path); if (!string.IsNullOrEmpty(@default)) { File(@default); return; } if (AllowEnumerate) Response.Data = ListChildren(path); else Respond403(path); } /// <summary>在指定目录下搜索默认文件。</summary> /// <returns>完整文件路径,当搜索失败时返回 NULL。</returns> protected string Default(string directory) { var subs = StorageUtility.GetSubFiles(directory); if (subs.Count < 0) return null; subs.Sort(); var names = new Dictionary<string, string>(subs.Count); foreach (var sub in subs) { var name = Path.GetFileName(sub); if (names.ContainsKey(name)) continue; names.Add(name, sub); } foreach (var sub in subs) { var name = Path.GetFileName(sub); var lower = name.ToLower(); if (lower == name) continue; if (names.ContainsKey(lower)) continue; names.Add(lower, sub); } 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) { var path = GetRoot(); if (!string.IsNullOrEmpty(urlPath)) { foreach (var split in urlPath.Split('/')) { var seg = split.ToTrim(); if (string.IsNullOrEmpty(seg)) continue; if (seg == "." || seg == "..") continue; path = StorageUtility.CombinePath(path, seg); } } return path; } // Server Side Includes string ReadWithSSI(string path, int recursive = 0) { 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 ""; // 按首尾截取。 const string left = "<!--"; const string right = "-->"; const string head = "#include virtual="; var sb = new StringBuilder(); var text = html; 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 subPath = MapPath(temp); var subText = ReadWithSSI(subPath, recursive + 1); if (subText != null && subText.Length > 0) sb.Append(subText); } else { sb.Append(left); sb.Append(inner); sb.Append(right); } text = text.Substring(length + right.Length); } var output = sb.ToString(); return output; } /// <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; } static 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 } }