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.

406 lines
14 KiB

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 web;
}
var @static = StorageUtility.CombinePath(app, "static");
if (System.IO.Directory.Exists(@static))
{
_root = new Class<string>(@static);
return @static;
}
_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
}
}