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.
522 lines
20 KiB
522 lines
20 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;
|
|
|
|
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(Root());
|
|
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 Root()
|
|
{
|
|
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) => 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 = 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 = Root();
|
|
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;
|
|
}
|
|
|
|
static string Mime(string extension)
|
|
{
|
|
const string Default = "application/octet-stream";
|
|
if (string.IsNullOrEmpty(extension)) return Default;
|
|
|
|
var lower = extension.ToLower();
|
|
switch (lower)
|
|
{
|
|
// text/plain; charset=utf-8
|
|
case "css": return "text/css";
|
|
case "htm": return "text/html";
|
|
case "html": return "text/html";
|
|
case "ini": return "text/ini";
|
|
case "js": return "application/javascript";
|
|
case "json": return "text/json";
|
|
case "shtml": return "text/html";
|
|
case "sh": return "text/plain";
|
|
case "txt": return "text/plain";
|
|
}
|
|
switch (lower)
|
|
{
|
|
case "jad": return "text/vnd.sun.j2me.app-descriptor";
|
|
case "m3u8": return "text/vnd.apple.mpegurl"; // application/vnd.apple.mpegurl
|
|
case "xml": return "text/xml";
|
|
case "htc": return "text/x-component";
|
|
case "mml": return "text/mathml";
|
|
case "wml": return "text/vnd.wap.wml";
|
|
}
|
|
switch (lower)
|
|
{
|
|
case "3gp": return "video/3gpp";
|
|
case "3gpp": return "video/3gpp";
|
|
case "7z": return "application/x-7z-compressed";
|
|
case "ai": return "application/postscript";
|
|
case "asf": return "video/x-ms-asf";
|
|
case "asx": return "video/x-ms-asf";
|
|
case "atom": return "application/atom+xml";
|
|
case "avi": return "video/x-msvideo";
|
|
case "bmp": return "image/x-ms-bmp";
|
|
case "cco": return "application/x-cocoa";
|
|
case "crt": return "application/x-x509-ca-cert";
|
|
case "der": return "application/x-x509-ca-cert";
|
|
case "doc": return "application/msword";
|
|
case "docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
case "ear": return "application/java-archive";
|
|
case "eot": return "application/vnd.ms-fontobject";
|
|
case "eps": return "application/postscript";
|
|
case "flv": return "video/x-flv";
|
|
case "gif": return "image/gif";
|
|
case "hqx": return "application/mac-binhex40";
|
|
case "ico": return "image/x-icon";
|
|
case "jar": return "application/java-archive";
|
|
case "jardiff": return "application/x-java-archive-diff";
|
|
case "jng": return "image/x-jng";
|
|
case "jnlp": return "application/x-java-jnlp-file";
|
|
case "jpeg": return "image/jpeg";
|
|
case "jpg": return "image/jpeg";
|
|
case "kar": return "audio/midi";
|
|
case "kml": return "application/vnd.google-earth.kml+xml";
|
|
case "kmz": return "application/vnd.google-earth.kmz";
|
|
case "m4a": return "audio/x-m4a";
|
|
case "m4v": return "video/x-m4v";
|
|
case "mid": return "audio/midi";
|
|
case "midi": return "audio/midi";
|
|
case "mng": return "video/x-mng";
|
|
case "mov": return "video/quicktime";
|
|
case "mp3": return "audio/mpeg";
|
|
case "mp4": return "video/mp4";
|
|
case "mpeg": return "video/mpeg";
|
|
case "mpg": return "video/mpeg";
|
|
case "odg": return "application/vnd.oasis.opendocument.graphics";
|
|
case "odp": return "application/vnd.oasis.opendocument.presentation";
|
|
case "ods": return "application/vnd.oasis.opendocument.spreadsheet";
|
|
case "odt": return "application/vnd.oasis.opendocument.text";
|
|
case "ogg": return "audio/ogg";
|
|
case "pdb": return "application/x-pilot";
|
|
case "pdf": return "application/pdf";
|
|
case "pem": return "application/x-x509-ca-cert";
|
|
case "pl": return "application/x-perl";
|
|
case "pm": return "application/x-perl";
|
|
case "png": return "image/png";
|
|
case "ppt": return "application/vnd.ms-powerpoint";
|
|
case "pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
|
case "prc": return "application/x-pilot";
|
|
case "ps": return "application/postscript";
|
|
case "ra": return "audio/x-realaudio";
|
|
case "rar": return "application/x-rar-compressed";
|
|
case "rpm": return "application/x-redhat-package-manager";
|
|
case "rss": return "application/rss+xml";
|
|
case "rtf": return "application/rtf";
|
|
case "run": return "application/x-makeself";
|
|
case "sea": return "application/x-sea";
|
|
case "sit": return "application/x-stuffit";
|
|
case "svg": return "image/svg+xml";
|
|
case "svgz": return "image/svg+xml";
|
|
case "swf": return "application/x-shockwave-flash";
|
|
case "tcl": return "application/x-tcl";
|
|
case "tif": return "image/tiff";
|
|
case "tiff": return "image/tiff";
|
|
case "tk": return "application/x-tcl";
|
|
case "ts": return "video/mp2t";
|
|
case "war": return "application/java-archive";
|
|
case "wbmp": return "image/vnd.wap.wbmp";
|
|
case "webm": return "video/webm";
|
|
case "webp": return "image/webp";
|
|
case "wmlc": return "application/vnd.wap.wmlc";
|
|
case "wmv": return "video/x-ms-wmv";
|
|
case "woff": return "font/woff";
|
|
case "woff2": return "font/woff2";
|
|
case "xhtml": return "application/xhtml+xml";
|
|
case "xls": return "application/vnd.ms-excel";
|
|
case "xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
case "xpi": return "application/x-xpinstall";
|
|
case "xspf": return "application/xspf+xml";
|
|
case "zip": return "application/zip";
|
|
}
|
|
return Default;
|
|
}
|
|
|
|
#endregion
|
|
|
|
}
|
|
|
|
}
|
|
|