节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
21.85 KiB
Stardust
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net;
using System.Text;
using NewLife;
using NewLife.Log;

namespace StarGateway.Proxy;

/*
 * 静态文件处理功能清单(相对常规中间件的完善项):
 * 1. GET/HEAD:HEAD 只返回头部,不发送响应体(修复原先 HEAD 仍返回 body 的协议错误)
 * 2. Range 分片:支持 bytes=start-end / bytes=start- / bytes=-suffix,返回 206 + Content-Range,含 If-Range 校验
 * 3. 条件请求:ETag / Last-Modified + If-None-Match / If-Modified-Since,命中返回 304(不读文件)
 * 4. 缓存头:HTML 类 no-cache,其它资源 public, max-age=3600
 * 5. 安全头:统一 X-Content-Type-Options: nosniff;目录浏览 HTML 编码防 XSS;路径穿越防护收口(带尾分隔符校验)
 * 6. SPA 回退:路径无后缀或以 / 结尾且启用时,直接返回默认首页(支持 history 路由);缺失的带后缀资源正确返回 404
 * 7. 目录无尾斜杠 301 重定向补斜杠(规范化)
 * 8. 大文件保护:全量读取受 MaxFileSize 限制,超出返回 413;Range 仅读所需分片
 * 9. MIME 类型补全(wasm / webm / mp4 / mp3 / csv / mjs / vue / otf / bmp / tiff / webmanifest 等)
 */

/// <summary>静态文件处理器。从本地文件系统读取文件并构建HTTP响应</summary>
public class StaticFileHandler
{
    #region 属性
    /// <summary>MIME类型映射</summary>
    private static readonly ConcurrentDictionary<String, String> _mimeTypes = new(StringComparer.OrdinalIgnoreCase);

    /// <summary>日志</summary>
    public ILog Log { get; set; }

    /// <summary>是否启用日志</summary>
    public Boolean LogEnabled { get; set; }

    /// <summary>全量读取单文件的最大字节数(单位:字节)。默认 50MB(50 * 1024 * 1024 = 52,428,800 字节),超出返回 413。Range 分片只读取所需区间,不受此限制</summary>
    public Int64 MaxFileSize { get; set; } = 50 * 1024 * 1024;
    #endregion

    #region 构造
    static StaticFileHandler()
    {
        // 常用MIME类型
        _mimeTypes[".html"] = "text/html; charset=utf-8";
        _mimeTypes[".htm"] = "text/html; charset=utf-8";
        _mimeTypes[".css"] = "text/css; charset=utf-8";
        _mimeTypes[".js"] = "application/javascript; charset=utf-8";
        _mimeTypes[".mjs"] = "text/javascript; charset=utf-8";
        _mimeTypes[".json"] = "application/json; charset=utf-8";
        _mimeTypes[".webmanifest"] = "application/manifest+json";
        _mimeTypes[".xml"] = "application/xml; charset=utf-8";
        _mimeTypes[".txt"] = "text/plain; charset=utf-8";
        _mimeTypes[".csv"] = "text/csv; charset=utf-8";
        _mimeTypes[".vue"] = "text/plain; charset=utf-8";
        _mimeTypes[".svg"] = "image/svg+xml";
        _mimeTypes[".png"] = "image/png";
        _mimeTypes[".jpg"] = "image/jpeg";
        _mimeTypes[".jpeg"] = "image/jpeg";
        _mimeTypes[".gif"] = "image/gif";
        _mimeTypes[".bmp"] = "image/bmp";
        _mimeTypes[".tiff"] = "image/tiff";
        _mimeTypes[".ico"] = "image/x-icon";
        _mimeTypes[".webp"] = "image/webp";
        _mimeTypes[".woff"] = "font/woff";
        _mimeTypes[".woff2"] = "font/woff2";
        _mimeTypes[".ttf"] = "font/ttf";
        _mimeTypes[".otf"] = "font/otf";
        _mimeTypes[".eot"] = "application/vnd.ms-fontobject";
        _mimeTypes[".map"] = "application/json";
        _mimeTypes[".pdf"] = "application/pdf";
        _mimeTypes[".zip"] = "application/zip";
        _mimeTypes[".gz"] = "application/gzip";
        _mimeTypes[".wasm"] = "application/wasm";
        _mimeTypes[".webm"] = "video/webm";
        _mimeTypes[".mp4"] = "video/mp4";
        _mimeTypes[".mp3"] = "audio/mpeg";
    }
    #endregion

    #region 方法
    /// <summary>尝试处理静态文件请求(不接收请求头,Range/304 等能力不可用)</summary>
    public Boolean TryHandle(String method, String path, String staticRoot, String indexFile, Boolean directoryBrowse, Boolean spaFallback, out Byte[] response)
        => TryHandle(method, path, staticRoot, indexFile, directoryBrowse, spaFallback, null, out response);

    /// <summary>尝试处理静态文件请求</summary>
    /// <param name="method">HTTP方法</param>
    /// <param name="path">请求路径</param>
    /// <param name="staticRoot">静态文件根目录</param>
    /// <param name="indexFile">默认首页文件名</param>
    /// <param name="directoryBrowse">是否允许目录浏览</param>
    /// <param name="spaFallback">SPA回退。路径无后缀或以/结尾时直接返回默认首页,用于支持前端history路由模式</param>
    /// <param name="headers">请求头(用于 Range / If-Range / If-None-Match / If-Modified-Since 等)</param>
    /// <param name="response">HTTP响应字节数组,若返回true则包含完整响应</param>
    /// <returns>是否已处理(true=已处理,不需要继续转发)</returns>
    public Boolean TryHandle(String method, String path, String staticRoot, String indexFile, Boolean directoryBrowse, Boolean spaFallback, IDictionary<String, String> headers, out Byte[] response)
    {
        response = null;

        // 只处理 GET/HEAD
        if (!method.EqualIgnoreCase("GET", "HEAD")) return false;

        indexFile ??= "index.html";

        // 安全检查:根目录必须存在
        var root = Path.GetFullPath(staticRoot);
        if (!Directory.Exists(root))
        {
            WriteLog("静态文件根目录不存在: {0}", root);
            response = BuildError(404, "Not Found");
            return true;
        }
        // 根目录补尾分隔符,便于精确判定“仍在根目录下”
        var rootWithSep = root.EndsWith(Path.DirectorySeparatorChar) ? root : root + Path.DirectorySeparatorChar;

        // 规范化请求路径
        var requestPath = path;
        // 去除查询参数
        var qIndex = requestPath.IndexOf('?');
        if (qIndex >= 0) requestPath = requestPath[..qIndex];

        // 解码URL编码(仅一次,避免双重解码绕过)
        requestPath = Uri.UnescapeDataString(requestPath);

        // 用于 SPA 路由判定的原始路径(未补 index)
        var routePath = requestPath;

        // 路径以/结尾或为空,使用默认首页
        if (requestPath.EndsWith("/") || requestPath.IsNullOrEmpty())
        {
            requestPath += indexFile;
        }

        // 构建本地文件路径并规范化
        var localPath = root + requestPath.Replace('/', Path.DirectorySeparatorChar);
        localPath = Path.GetFullPath(localPath);

        // 安全检查:确保解析后的路径仍在根目录下(带尾分隔符,杜绝 C:\web 匹配 C:\webx 类边界)
        if (!localPath.StartsWith(rootWithSep, StringComparison.OrdinalIgnoreCase))
        {
            WriteLog("路径穿越攻击被阻止: {0}", path);
            response = BuildError(403, "Forbidden");
            return true;
        }

        // 是否是 SPA 路由型路径(无后缀或以/结尾)
        var routeLike = spaFallback && (routePath.EndsWith("/") || !Path.HasExtension(routePath));

        // 文件存在直接服务(含 Range/304/HEAD)
        if (File.Exists(localPath))
        {
            return ServeFile(localPath, method, headers, out response);
        }

        // 目录处理
        if (Directory.Exists(localPath))
        {
            // 目录未带尾斜杠 → 301 规范化补斜杠(客户端重试后走正常流程)
            if (!routePath.EndsWith("/"))
            {
                response = BuildRedirect(routePath + "/");
                return true;
            }

            if (directoryBrowse)
            {
                response = BuildDirectoryListing(requestPath, localPath, root);
                return true;
            }

            // 目录内默认页
            var dirIndex = Path.Combine(localPath, indexFile);
            if (File.Exists(dirIndex))
            {
                return ServeFile(dirIndex, method, headers, out response);
            }

            // SPA 路由型目录 → 回退默认首页
            if (routeLike)
            {
                var indexPath = Path.Combine(root, indexFile);
                if (File.Exists(indexPath))
                {
                    return ServeFile(indexPath, method, headers, out response);
                }
            }

            WriteLog("静态目录无默认页: {0}", localPath);
            response = BuildError(404, "Not Found");
            return true;
        }

        // 文件与目录都不存在:SPA 路由型路径回退默认首页,其余(含缺失的带后缀资源)正确 404
        if (routeLike)
        {
            var indexPath = Path.Combine(root, indexFile);
            if (File.Exists(indexPath))
            {
                if (LogEnabled)
                    WriteLog("SPA回退: {0} -> {1}", path, indexFile);

                return ServeFile(indexPath, method, headers, out response);
            }
        }

        WriteLog("静态文件不存在: {0}", localPath);
        response = BuildError(404, "Not Found");
        return true;
    }

    /// <summary>读取并构建单个文件的响应(统一处理 HEAD / Range / 304 / 缓存头 / 安全头)</summary>
    private Boolean ServeFile(String localPath, String method, IDictionary<String, String> headers, out Byte[] response)
    {
        response = null;
        try
        {
            var fileInfo = new FileInfo(localPath);
            var ext = Path.GetExtension(localPath);
            var contentType = _mimeTypes.GetValueOrDefault(ext, "application/octet-stream");
            var total = fileInfo.Length;
            var lastModified = fileInfo.LastWriteTimeUtc;
            var etag = $"\"{total}-{lastModified.Ticks}\"";

            var isHead = method.EqualIgnoreCase("HEAD");

            // 条件请求:If-None-Match / If-Modified-Since → 304
            var inm = GetHeader(headers, "If-None-Match");
            if (!inm.IsNullOrEmpty())
            {
                var a = etag.Trim('"');
                var b = inm.Trim('"');
                if (b.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) b = b[2..];
                if (a == b) { response = BuildNotModified(etag, lastModified); return true; }
            }
            var ims = GetHeader(headers, "If-Modified-Since");
            if (!ims.IsNullOrEmpty() && DateTime.TryParse(ims, out var imsDate) && lastModified <= imsDate.ToUniversalTime().AddSeconds(1))
            {
                response = BuildNotModified(etag, lastModified);
                return true;
            }

            // Range 分片:206
            var isRange = false;
            Int64 start = 0, end = total - 1;
            var contentRange = "";
            var status = 200;
            var range = GetHeader(headers, "Range");
            if (!range.IsNullOrEmpty() && total > 0)
            {
                // If-Range 校验:与当前资源不匹配时忽略 Range,返回完整内容
                var applyRange = true;
                var ifRange = GetHeader(headers, "If-Range");
                if (!ifRange.IsNullOrEmpty() &&
                    ifRange != etag &&
                    !(DateTime.TryParse(ifRange, out var irDate) && lastModified <= irDate.ToUniversalTime().AddSeconds(1)))
                {
                    applyRange = false;
                }

                if (applyRange && ParseRange(range, total, out start, out end))
                {
                    isRange = true;
                    status = 206;
                    contentRange = $"bytes {start}-{end}/{total}";
                }
                else if (applyRange)
                {
                    // 无法满足的范围
                    response = BuildRangeNotSatisfiable(total);
                    return true;
                }
            }

            Byte[] data;
            Int64 contentLength;
            if (isHead)
            {
                // HEAD 不读取文件,仅返回头部
                data = Array.Empty<Byte>();
                contentLength = isRange ? end - start + 1 : total;
            }
            else if (isRange)
            {
                // 仅读取所需分片,避免大文件占用内存
                var count = (Int32)(end - start + 1);
                data = new Byte[count];
                using var fs = File.OpenRead(localPath);
                fs.Seek(start, SeekOrigin.Begin);
                var offset = 0;
                Int32 read;
                while (offset < count && (read = fs.Read(data, offset, count - offset)) > 0) offset += read;
                if (offset < count) Array.Resize(ref data, offset);
                contentLength = data.Length;
            }
            else
            {
                // 全量读取受最大文件限制保护
                if (total > MaxFileSize)
                {
                    response = BuildError(413, "Payload Too Large");
                    return true;
                }
                data = File.ReadAllBytes(localPath);
                contentLength = data.Length;
            }

            response = BuildFileResponse(status, contentType, data, contentLength, etag, lastModified, isRange, contentRange, isHead);

            if (LogEnabled)
                WriteLog("{0} {1} ({2}, {3})", isRange ? "206" : "200", localPath, contentType, GetSizeString(total));

            return true;
        }
        catch (Exception ex)
        {
            WriteLog("读取静态文件失败: {0} - {1}", localPath, ex.Message);
            response = BuildError(500, "Internal Server Error");
            return true;
        }
    }

    /// <summary>解析单个 Range 区间(bytes=start-end / bytes=start- / bytes=-suffix)</summary>
    private static Boolean ParseRange(String range, Int64 total, out Int64 start, out Int64 end)
    {
        start = 0;
        end = total - 1;
        if (!range.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase)) return false;
        var spec = range["bytes=".Length..].Trim();
        // 仅支持单个区间
        if (spec.IndexOf(',') >= 0) return false;
        var parts = spec.Split('-');
        if (parts.Length != 2) return false;

        if (parts[0].Length == 0)
        {
            // 后缀形式:-500 → 最后 500 字节
            if (!Int64.TryParse(parts[1], out var suffix) || suffix <= 0) return false;
            start = Math.Max(0, total - suffix);
            end = total - 1;
        }
        else
        {
            if (!Int64.TryParse(parts[0], out start)) return false;
            if (parts[1].Length == 0)
                end = total - 1;
            else if (!Int64.TryParse(parts[1], out end)) return false;
        }

        if (start < 0 || end < start || start >= total) return false;
        if (end >= total) end = total - 1;
        return true;
    }

    /// <summary>不区分大小写读取请求头</summary>
    private static String GetHeader(IDictionary<String, String> headers, String name)
    {
        if (headers == null) return null;
        foreach (var kv in headers)
            if (kv.Key.EqualIgnoreCase(name)) return kv.Value;
        return null;
    }

    private static String StatusReason(Int32 code) => code switch
    {
        200 => "OK",
        206 => "Partial Content",
        301 => "Moved Permanently",
        304 => "Not Modified",
        403 => "Forbidden",
        404 => "Not Found",
        413 => "Payload Too Large",
        416 => "Range Not Satisfiable",
        500 => "Internal Server Error",
        _ => "OK"
    };

    private static Boolean IsNoCache(String contentType)
        => contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase);

    private Byte[] BuildFileResponse(Int32 status, String contentType, Byte[] data, Int64 contentLength, String etag, DateTime lastModified, Boolean isRange, String contentRange, Boolean isHead)
    {
        var sb = new StringBuilder();
        sb.Append($"HTTP/1.1 {status} {StatusReason(status)}\r\n");
        sb.Append($"Content-Type: {contentType}\r\n");
        sb.Append($"Content-Length: {contentLength}\r\n");
        if (isRange) sb.Append($"Content-Range: {contentRange}\r\n");
        sb.Append("Accept-Ranges: bytes\r\n");
        sb.Append($"ETag: {etag}\r\n");
        sb.Append($"Last-Modified: {lastModified.ToUniversalTime():R}\r\n");
        sb.Append($"Cache-Control: {(IsNoCache(contentType) ? "no-cache" : "public, max-age=3600")}\r\n");
        sb.Append("X-Content-Type-Options: nosniff\r\n");
        sb.Append("Connection: keep-alive\r\n");
        sb.Append("\r\n");

        var headerBytes = Encoding.UTF8.GetBytes(sb.ToString());
        if (isHead || data.Length == 0) return headerBytes;

        var result = new Byte[headerBytes.Length + data.Length];
        Buffer.BlockCopy(headerBytes, 0, result, 0, headerBytes.Length);
        Buffer.BlockCopy(data, 0, result, headerBytes.Length, data.Length);
        return result;
    }

    private Byte[] BuildNotModified(String etag, DateTime lastModified)
    {
        var sb = new StringBuilder();
        sb.Append("HTTP/1.1 304 Not Modified\r\n");
        sb.Append($"ETag: {etag}\r\n");
        sb.Append($"Last-Modified: {lastModified.ToUniversalTime():R}\r\n");
        sb.Append("Cache-Control: no-cache\r\n");
        sb.Append("X-Content-Type-Options: nosniff\r\n");
        sb.Append("Connection: keep-alive\r\n");
        sb.Append("\r\n");
        return Encoding.UTF8.GetBytes(sb.ToString());
    }

    private Byte[] BuildRangeNotSatisfiable(Int64 total)
    {
        var sb = new StringBuilder();
        sb.Append("HTTP/1.1 416 Range Not Satisfiable\r\n");
        sb.Append($"Content-Range: bytes */{total}\r\n");
        sb.Append("Content-Length: 0\r\n");
        sb.Append("Connection: close\r\n");
        sb.Append("\r\n");
        return Encoding.UTF8.GetBytes(sb.ToString());
    }

    private Byte[] BuildRedirect(String location)
    {
        // 防止头注入
        location = location.Replace("\r", "").Replace("\n", "");
        var sb = new StringBuilder();
        sb.Append("HTTP/1.1 301 Moved Permanently\r\n");
        sb.Append($"Location: {location}\r\n");
        sb.Append("Content-Length: 0\r\n");
        sb.Append("Connection: close\r\n");
        sb.Append("\r\n");
        return Encoding.UTF8.GetBytes(sb.ToString());
    }

    private Byte[] BuildError(Int32 statusCode, String message)
    {
        var body = $"<html><body><h1>{statusCode} {message}</h1></body></html>";
        var data = Encoding.UTF8.GetBytes(body);
        var sb = new StringBuilder();
        sb.Append($"HTTP/1.1 {statusCode} {message}\r\n");
        sb.Append("Content-Type: text/html; charset=utf-8\r\n");
        sb.Append($"Content-Length: {data.Length}\r\n");
        sb.Append("X-Content-Type-Options: nosniff\r\n");
        sb.Append("Connection: close\r\n");
        sb.Append("\r\n");

        var headerBytes = Encoding.UTF8.GetBytes(sb.ToString());
        var result = new Byte[headerBytes.Length + data.Length];
        Buffer.BlockCopy(headerBytes, 0, result, 0, headerBytes.Length);
        Buffer.BlockCopy(data, 0, result, headerBytes.Length, data.Length);

        return result;
    }

    private Byte[] BuildDirectoryListing(String requestPath, String dirPath, String rootPath)
    {
        var encPath = WebUtility.HtmlEncode(requestPath);
        var sb = new StringBuilder();
        sb.Append("<!DOCTYPE html><html><head><meta charset=\"utf-8\">");
        sb.Append($"<title>目录: {encPath}</title>");
        sb.Append("<style>body{font-family:sans-serif;margin:20px}li{padding:4px 0}a{text-decoration:none;color:#0366d6}a:hover{text-decoration:underline}</style>");
        sb.Append("</head><body>");
        sb.Append($"<h1>目录: {encPath}</h1><ul>");

        // 如果不是根目录,显示上级
        if (requestPath != "/")
        {
            var parent = requestPath.TrimEnd('/');
            var idx = parent.LastIndexOf('/');
            var parentPath = idx >= 0 ? parent[..idx] : "/";
            if (parentPath.IsNullOrEmpty()) parentPath = "/";
            sb.Append($"<li><a href=\"{WebUtility.HtmlEncode(parentPath)}\">..</a></li>");
        }

        try
        {
            var dir = new DirectoryInfo(dirPath);
            foreach (var d in dir.GetDirectories())
                sb.Append($"<li><a href=\"{WebUtility.HtmlEncode(requestPath.TrimEnd('/') + "/" + d.Name)}\">{WebUtility.HtmlEncode(d.Name)}/</a></li>");

            foreach (var f in dir.GetFiles())
                sb.Append($"<li><a href=\"{WebUtility.HtmlEncode(requestPath.TrimEnd('/') + "/" + f.Name)}\">{WebUtility.HtmlEncode(f.Name)}</a> ({GetSizeString(f.Length)})</li>");
        }
        catch (Exception ex)
        {
            sb.Append($"<li>读取目录失败: {WebUtility.HtmlEncode(ex.Message)}</li>");
        }

        sb.Append("</ul></body></html>");

        var data = Encoding.UTF8.GetBytes(sb.ToString());
        var header = new StringBuilder();
        header.Append("HTTP/1.1 200 OK\r\n");
        header.Append("Content-Type: text/html; charset=utf-8\r\n");
        header.Append($"Content-Length: {data.Length}\r\n");
        header.Append("X-Content-Type-Options: nosniff\r\n");
        header.Append("Connection: keep-alive\r\n");
        header.Append("\r\n");

        var headerBytes = Encoding.UTF8.GetBytes(header.ToString());
        var result = new Byte[headerBytes.Length + data.Length];
        Buffer.BlockCopy(headerBytes, 0, result, 0, headerBytes.Length);
        Buffer.BlockCopy(data, 0, result, headerBytes.Length, data.Length);

        return result;
    }

    private void WriteLog(String format, params Object[] args)
    {
        if (LogEnabled && Log != null)
            Log.Info(format, args);
    }

    private static String GetSizeString(Int64 size)
    {
        if (size < 1024) return $"{size} B";
        if (size < 1024 * 1024) return $"{size / 1024.0:F1} KB";
        if (size < 1024 * 1024 * 1024) return $"{size / (1024.0 * 1024):F1} MB";
        return $"{size / (1024.0 * 1024 * 1024):F2} GB";
    }
    #endregion
}