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

4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
  1. using System;
  2. using System.Collections.Generic;
  3. using System.IO;
  4. using System.Text;
  5. namespace Apewer.Web
  6. {
  7. /// <summary>静态站点控制器。</summary>
  8. public class StaticController : ApiController
  9. {
  10. /// <summary>允许服务器端包含(Server Side Include)。</summary>
  11. /// <remarks>默认值:允许。</remarks>
  12. protected bool AllowSSI { get; set; } = true;
  13. /// <summary>当执行目录且没有默认文档时,枚举子目录和子文件。</summary>
  14. /// <remarks>默认值:不允许。</remarks>
  15. protected bool AllowEnumerate { get; set; } = false;
  16. List<string> PathSegments;
  17. Class<string> _root = null;
  18. /// <summary></summary>
  19. public StaticController() : base((c) => { ((StaticController)c).Initialize(); return false; }) { }
  20. void Initialize()
  21. {
  22. if (Request == null || Request.Url == null) return;
  23. var absolute = Request.Url.AbsolutePath;
  24. var split = absolute == null ? new string[0] : absolute.Split('/', '\\');
  25. PathSegments = new List<string>(split.Length);
  26. foreach (var item in split)
  27. {
  28. var trim = TextUtility.Trim(TextUtility.DecodeUrl(item));
  29. if (string.IsNullOrEmpty(trim)) continue;
  30. if (trim == "." || trim == "..") continue;
  31. PathSegments.Add(trim);
  32. }
  33. absolute = TextUtility.Join("/", PathSegments);
  34. var path = MapPath(absolute);
  35. if (PathSegments.Count < 1) Directory(Root());
  36. else if (IsBlocked(PathSegments[0])) Respond404(path);
  37. else if (StorageUtility.FileExists(path)) File(path);
  38. else if (StorageUtility.DirectoryExists(path)) Directory(path);
  39. else Respond404(path);
  40. }
  41. #region virtual
  42. /// <summary>响应 404 状态。</summary>
  43. /// <remarks>默认:设置状态为 404,不输出内容。</remarks>
  44. protected virtual void Respond404(string path)
  45. {
  46. Response.Model = new ApiStatusModel(404);
  47. }
  48. /// <summary>响应 403 状态。</summary>
  49. /// <remarks>默认:设置状态为 403,不输出内容。</remarks>
  50. protected virtual void Respond403(string path)
  51. {
  52. Response.Model = new ApiStatusModel(403);
  53. }
  54. /// <summary>获取此静态站点的目录。</summary>
  55. protected virtual string Root()
  56. {
  57. if (_root) return _root.Value;
  58. var app = RuntimeUtility.ApplicationPath;
  59. var paths = StorageUtility.GetSubFiles(app);
  60. foreach (var path in paths)
  61. {
  62. var split = path.Split('/', '\\');
  63. var lower = split[split.Length - 1];
  64. switch (lower)
  65. {
  66. case "index.html":
  67. case "index.htm":
  68. case "default.html":
  69. case "default.htm":
  70. case "favicon.ico":
  71. _root = new Class<string>(app);
  72. return app;
  73. }
  74. }
  75. var www = StorageUtility.CombinePath(app, "www");
  76. if (System.IO.Directory.Exists(www))
  77. {
  78. _root = new Class<string>(www);
  79. return www;
  80. }
  81. var web = StorageUtility.CombinePath(app, "web");
  82. if (System.IO.Directory.Exists(web))
  83. {
  84. _root = new Class<string>(web);
  85. return www;
  86. }
  87. var @static = StorageUtility.CombinePath(app, "static");
  88. if (System.IO.Directory.Exists(@static))
  89. {
  90. _root = new Class<string>(@static);
  91. return www;
  92. }
  93. _root = new Class<string>(app);
  94. return app;
  95. }
  96. /// <summary>从扩展名获取内容类型。</summary>
  97. protected virtual string ContentType(string extension) => Mime(extension);
  98. /// <summary>从扩展名和文件路径获取过期时间。</summary>
  99. /// <remarks>默认值:0,不缓存。</remarks>
  100. protected virtual int Expires(string extension, string path) => 0;
  101. /// <summary>已解析到本地文本路径,执行此路径。</summary>
  102. /// <remarks>默认:输出文件内容,文件不存在时输出 404 状态。</remarks>
  103. protected virtual void File(string path)
  104. {
  105. if (!System.IO.File.Exists(path))
  106. {
  107. Respond404(path);
  108. return;
  109. }
  110. // 获取文件扩展名。
  111. var ext = Path.GetExtension(path).Lower();
  112. if (ext.Length > 1 && ext.StartsWith(".")) ext = ext.Substring(1);
  113. // 按扩展名获取缓存过期时间。
  114. var expires = Expires(ext, path);
  115. // 按扩展名获取 Content-Type。
  116. var type = ContentType(ext);
  117. if (string.IsNullOrEmpty(type)) type = Mime(ext);
  118. // Server Side Includes
  119. if (AllowSSI && ext == "html" || ext == "htm" || ext == "shtml")
  120. {
  121. var html = ReadWithSSI(path);
  122. var bytes = html.Bytes();
  123. var model = new ApiBytesModel();
  124. if (expires > 0) model.Expires = expires;
  125. model.ContentType = type;
  126. model.Bytes = bytes;
  127. Response.Model = model;
  128. }
  129. else
  130. {
  131. var stream = StorageUtility.OpenFile(path, true);
  132. var model = new ApiStreamModel();
  133. if (expires > 0) model.Expires = expires;
  134. model.ContentType = type;
  135. model.AutoDispose = true;
  136. model.Stream = stream;
  137. Response.Model = model;
  138. }
  139. }
  140. /// <summary>已解析到本地目录路径,执行此路径。</summary>
  141. /// <remarks>默认:输出文件内容,文件不存在时输出 404 状态。</remarks>
  142. protected virtual void Directory(string path)
  143. {
  144. if (!System.IO.Directory.Exists(path))
  145. {
  146. Respond404(path);
  147. return;
  148. }
  149. var @default = Default(path);
  150. if (!string.IsNullOrEmpty(@default))
  151. {
  152. File(@default);
  153. return;
  154. }
  155. if (AllowEnumerate) Response.Data = ListChildren(path);
  156. else Respond403(path);
  157. }
  158. /// <summary>在指定目录下搜索默认文件。</summary>
  159. /// <returns>完整文件路径,当搜索失败时返回 NULL。</returns>
  160. protected string Default(string directory)
  161. {
  162. var subs = StorageUtility.GetSubFiles(directory);
  163. if (subs.Count < 0) return null;
  164. subs.Sort();
  165. var names = new Dictionary<string, string>(subs.Count);
  166. foreach (var sub in subs)
  167. {
  168. var name = Path.GetFileName(sub);
  169. if (names.ContainsKey(name)) continue;
  170. names.Add(name, sub);
  171. }
  172. foreach (var sub in subs)
  173. {
  174. var name = Path.GetFileName(sub);
  175. var lower = name.ToLower();
  176. if (lower == name) continue;
  177. if (names.ContainsKey(lower)) continue;
  178. names.Add(lower, sub);
  179. }
  180. if (names.ContainsKey("index.html")) return names["index.html"];
  181. if (names.ContainsKey("index.htm")) return names["index.htm"];
  182. if (names.ContainsKey("default.html")) return names["default.html"];
  183. if (names.ContainsKey("default.htm")) return names["default.htm"];
  184. return null;
  185. }
  186. #endregion
  187. #region private
  188. // 解析 URL 的路径,获取本地路径。
  189. string MapPath(string urlPath)
  190. {
  191. var path = Root();
  192. if (!string.IsNullOrEmpty(urlPath))
  193. {
  194. foreach (var split in urlPath.Split('/'))
  195. {
  196. var seg = split.ToTrim();
  197. if (string.IsNullOrEmpty(seg)) continue;
  198. if (seg == "." || seg == "..") continue;
  199. path = StorageUtility.CombinePath(path, seg);
  200. }
  201. }
  202. return path;
  203. }
  204. // Server Side Includes
  205. string ReadWithSSI(string path, int recursive = 0)
  206. {
  207. if (recursive > 10) return "";
  208. var input = StorageUtility.ReadFile(path, true);
  209. if (input == null || input.LongLength < 1) return "";
  210. // 尝试解码。
  211. var html = TextUtility.FromBytes(input);
  212. if (string.IsNullOrEmpty(html)) return "";
  213. // 按首尾截取。
  214. const string left = "<!--";
  215. const string right = "-->";
  216. const string head = "#include virtual=";
  217. var sb = new StringBuilder();
  218. var text = html;
  219. while (true)
  220. {
  221. var offset = text.IndexOf(left);
  222. if (offset < 0)
  223. {
  224. sb.Append(text);
  225. break;
  226. }
  227. if (offset > 0)
  228. {
  229. sb.Append(text.Substring(0, offset));
  230. text = text.Substring(offset + left.Length);
  231. }
  232. else text = text.Substring(left.Length);
  233. var length = text.IndexOf(right);
  234. if (length < 1)
  235. {
  236. sb.Append(left);
  237. sb.Append(text);
  238. break;
  239. }
  240. var inner = text.Substring(0, length);
  241. var temp = inner.ToTrim();
  242. if (temp.StartsWith(head))
  243. {
  244. temp = temp.Substring(head.Length);
  245. temp = temp.Replace("\"", "");
  246. var subPath = MapPath(temp);
  247. var subText = ReadWithSSI(subPath, recursive + 1);
  248. if (subText != null && subText.Length > 0) sb.Append(subText);
  249. }
  250. else
  251. {
  252. sb.Append(left);
  253. sb.Append(inner);
  254. sb.Append(right);
  255. }
  256. text = text.Substring(length + right.Length);
  257. }
  258. var output = sb.ToString();
  259. return output;
  260. }
  261. /// <summary>列出指定目录的子项。</summary>
  262. Json ListChildren(string directory)
  263. {
  264. if (!System.IO.Directory.Exists(directory)) return null;
  265. var json = Json.NewObject();
  266. json.SetProperty("directories", ListDirectories(directory));
  267. json.SetProperty("files", ListFiles(directory));
  268. return json;
  269. }
  270. Json ListDirectories(string directory)
  271. {
  272. var array = Json.NewArray();
  273. var subs = StorageUtility.GetSubDirectories(directory);
  274. subs.Sort();
  275. foreach (var sub in subs)
  276. {
  277. var split = sub.Split('/', '\\');
  278. var name = split[split.Length - 1];
  279. if (IsBlocked(name)) continue;
  280. var json = Json.NewObject();
  281. json.SetProperty("name", name);
  282. try
  283. {
  284. var info = new DirectoryInfo(sub);
  285. json.SetProperty("modified", info.LastWriteTimeUtc.Stamp());
  286. }
  287. catch { }
  288. array.AddItem(json);
  289. }
  290. return array;
  291. }
  292. Json ListFiles(string directory)
  293. {
  294. var array = Json.NewArray();
  295. var subs = StorageUtility.GetSubFiles(directory);
  296. subs.Sort();
  297. foreach (var sub in subs)
  298. {
  299. var name = Path.GetFileName(sub);
  300. if (IsBlocked(name)) continue;
  301. var json = Json.NewObject();
  302. json.SetProperty("name", name);
  303. try
  304. {
  305. var info = new FileInfo(sub);
  306. json.SetProperty("size", info.Length);
  307. json.SetProperty("modified", info.LastWriteTimeUtc.Stamp());
  308. }
  309. catch { }
  310. array.AddItem(json);
  311. }
  312. return array;
  313. }
  314. static bool IsBlocked(string segment)
  315. {
  316. if (string.IsNullOrEmpty(segment)) return true;
  317. var lower = segment.ToLower();
  318. switch (lower)
  319. {
  320. case ".":
  321. case "..":
  322. // Synology
  323. case "@eadir":
  324. case "#recycle":
  325. // Windows
  326. case "$recycle.bin":
  327. case "recycler":
  328. case "system volume information":
  329. case "desktop.ini":
  330. case "thumbs.db":
  331. // macOS
  332. case ".ds_store":
  333. case ".localized":
  334. // IIS
  335. case "app_code":
  336. case "app_data":
  337. case "aspnet_client":
  338. case "bin":
  339. case "web.config":
  340. return true;
  341. }
  342. return false;
  343. }
  344. static string Mime(string extension)
  345. {
  346. const string Default = "application/octet-stream";
  347. if (string.IsNullOrEmpty(extension)) return Default;
  348. var lower = extension.ToLower();
  349. switch (lower)
  350. {
  351. // text/plain; charset=utf-8
  352. case "css": return "text/css";
  353. case "htm": return "text/html";
  354. case "html": return "text/html";
  355. case "ini": return "text/ini";
  356. case "js": return "application/javascript";
  357. case "json": return "text/json";
  358. case "shtml": return "text/html";
  359. case "sh": return "text/plain";
  360. case "txt": return "text/plain";
  361. }
  362. switch (lower)
  363. {
  364. case "jad": return "text/vnd.sun.j2me.app-descriptor";
  365. case "m3u8": return "text/vnd.apple.mpegurl"; // application/vnd.apple.mpegurl
  366. case "xml": return "text/xml";
  367. case "htc": return "text/x-component";
  368. case "mml": return "text/mathml";
  369. case "wml": return "text/vnd.wap.wml";
  370. }
  371. switch (lower)
  372. {
  373. case "3gp": return "video/3gpp";
  374. case "3gpp": return "video/3gpp";
  375. case "7z": return "application/x-7z-compressed";
  376. case "ai": return "application/postscript";
  377. case "asf": return "video/x-ms-asf";
  378. case "asx": return "video/x-ms-asf";
  379. case "atom": return "application/atom+xml";
  380. case "avi": return "video/x-msvideo";
  381. case "bmp": return "image/x-ms-bmp";
  382. case "cco": return "application/x-cocoa";
  383. case "crt": return "application/x-x509-ca-cert";
  384. case "der": return "application/x-x509-ca-cert";
  385. case "doc": return "application/msword";
  386. case "docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
  387. case "ear": return "application/java-archive";
  388. case "eot": return "application/vnd.ms-fontobject";
  389. case "eps": return "application/postscript";
  390. case "flv": return "video/x-flv";
  391. case "gif": return "image/gif";
  392. case "hqx": return "application/mac-binhex40";
  393. case "ico": return "image/x-icon";
  394. case "jar": return "application/java-archive";
  395. case "jardiff": return "application/x-java-archive-diff";
  396. case "jng": return "image/x-jng";
  397. case "jnlp": return "application/x-java-jnlp-file";
  398. case "jpeg": return "image/jpeg";
  399. case "jpg": return "image/jpeg";
  400. case "kar": return "audio/midi";
  401. case "kml": return "application/vnd.google-earth.kml+xml";
  402. case "kmz": return "application/vnd.google-earth.kmz";
  403. case "m4a": return "audio/x-m4a";
  404. case "m4v": return "video/x-m4v";
  405. case "mid": return "audio/midi";
  406. case "midi": return "audio/midi";
  407. case "mng": return "video/x-mng";
  408. case "mov": return "video/quicktime";
  409. case "mp3": return "audio/mpeg";
  410. case "mp4": return "video/mp4";
  411. case "mpeg": return "video/mpeg";
  412. case "mpg": return "video/mpeg";
  413. case "odg": return "application/vnd.oasis.opendocument.graphics";
  414. case "odp": return "application/vnd.oasis.opendocument.presentation";
  415. case "ods": return "application/vnd.oasis.opendocument.spreadsheet";
  416. case "odt": return "application/vnd.oasis.opendocument.text";
  417. case "ogg": return "audio/ogg";
  418. case "pdb": return "application/x-pilot";
  419. case "pdf": return "application/pdf";
  420. case "pem": return "application/x-x509-ca-cert";
  421. case "pl": return "application/x-perl";
  422. case "pm": return "application/x-perl";
  423. case "png": return "image/png";
  424. case "ppt": return "application/vnd.ms-powerpoint";
  425. case "pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
  426. case "prc": return "application/x-pilot";
  427. case "ps": return "application/postscript";
  428. case "ra": return "audio/x-realaudio";
  429. case "rar": return "application/x-rar-compressed";
  430. case "rpm": return "application/x-redhat-package-manager";
  431. case "rss": return "application/rss+xml";
  432. case "rtf": return "application/rtf";
  433. case "run": return "application/x-makeself";
  434. case "sea": return "application/x-sea";
  435. case "sit": return "application/x-stuffit";
  436. case "svg": return "image/svg+xml";
  437. case "svgz": return "image/svg+xml";
  438. case "swf": return "application/x-shockwave-flash";
  439. case "tcl": return "application/x-tcl";
  440. case "tif": return "image/tiff";
  441. case "tiff": return "image/tiff";
  442. case "tk": return "application/x-tcl";
  443. case "ts": return "video/mp2t";
  444. case "war": return "application/java-archive";
  445. case "wbmp": return "image/vnd.wap.wbmp";
  446. case "webm": return "video/webm";
  447. case "webp": return "image/webp";
  448. case "wmlc": return "application/vnd.wap.wmlc";
  449. case "wmv": return "video/x-ms-wmv";
  450. case "woff": return "font/woff";
  451. case "woff2": return "font/woff2";
  452. case "xhtml": return "application/xhtml+xml";
  453. case "xls": return "application/vnd.ms-excel";
  454. case "xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
  455. case "xpi": return "application/x-xpinstall";
  456. case "xspf": return "application/xspf+xml";
  457. case "zip": return "application/zip";
  458. }
  459. return Default;
  460. }
  461. #endregion
  462. }
  463. }