diff --git a/src/HttpFileServer/Converters/StringToUriConverter.cs b/src/HttpFileServer/Converters/StringToUriConverter.cs new file mode 100644 index 0000000..752493f --- /dev/null +++ b/src/HttpFileServer/Converters/StringToUriConverter.cs @@ -0,0 +1,27 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace HttpFileServer.Converters +{ + // Safely converts a string to a Uri for use with Hyperlink.NavigateUri. + // Returns null for null, empty, or otherwise invalid URI strings so that + // WPF does not throw the "Cannot convert ''" Error 23 at startup when the + // bound text property has not yet been populated. + public class StringToUriConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var s = value as string; + if (string.IsNullOrWhiteSpace(s)) + return null; + + return Uri.TryCreate(s, UriKind.Absolute, out var uri) ? uri : null; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return (value as Uri)?.ToString() ?? string.Empty; + } + } +} diff --git a/src/HttpFileServer/Handlers/HttpGetHandler.cs b/src/HttpFileServer/Handlers/HttpGetHandler.cs index f5b88a9..f26bdf9 100644 --- a/src/HttpFileServer/Handlers/HttpGetHandler.cs +++ b/src/HttpFileServer/Handlers/HttpGetHandler.cs @@ -263,6 +263,7 @@ protected async Task> GetResponseContentTypeAndStrea var fileExist = false; // Guard against path traversal: ensure resolved path stays within SourceDir + // or within the debug resource directory when one is configured. string safeRoot, safePath; try { @@ -274,8 +275,20 @@ protected async Task> GetResponseContentTypeAndStrea // Invalid path characters or other path resolution errors return new Tuple(contentType, null, false); } - if (!safePath.StartsWith(safeRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && - !safePath.Equals(safeRoot, StringComparison.OrdinalIgnoreCase)) + var insideSourceDir = safePath.Equals(safeRoot, StringComparison.OrdinalIgnoreCase) || + safePath.StartsWith(safeRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + var insideDebugDir = false; + if (!insideSourceDir && !string.IsNullOrWhiteSpace(_debugResourceDir)) + { + try + { + var safeDebugRoot = Path.GetFullPath(_debugResourceDir).TrimEnd(Path.DirectorySeparatorChar); + insideDebugDir = safePath.Equals(safeDebugRoot, StringComparison.OrdinalIgnoreCase) || + safePath.StartsWith(safeDebugRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + catch { } + } + if (!insideSourceDir && !insideDebugDir) { System.Diagnostics.Trace.TraceWarning($"Path traversal attempt blocked: resolved path outside SourceDir"); return new Tuple(contentType, null, false); diff --git a/src/HttpFileServer/Handlers/HttpPostFileHandler.cs b/src/HttpFileServer/Handlers/HttpPostFileHandler.cs index fcd08ee..735679c 100644 --- a/src/HttpFileServer/Handlers/HttpPostFileHandler.cs +++ b/src/HttpFileServer/Handlers/HttpPostFileHandler.cs @@ -1,11 +1,8 @@ using System; -using System.Collections.Generic; using System.IO; -using System.Linq; using System.Net; using System.Text; using System.Threading.Tasks; -using HttpFileServer.Web; using Newtonsoft.Json; namespace HttpFileServer.Handlers @@ -40,72 +37,47 @@ public override async Task ProcessRequest(HttpListenerContext context) return; } - if (request.ContentLength64 > int.MaxValue) + // POST URL 即为目标文件路径(由前端将文件名拼接到请求路径中) + var urlLocalPath = request.Url.LocalPath.TrimStart('/'); + var targetPath = Path.GetFullPath(Path.Combine(SourceDir, urlLocalPath.Replace('/', Path.DirectorySeparatorChar))); + // 安全校验:目标路径必须在服务根目录内 + var fullSourceDir = Path.GetFullPath(SourceDir).TrimEnd(Path.DirectorySeparatorChar); + if (!targetPath.Equals(fullSourceDir, StringComparison.OrdinalIgnoreCase) && + !targetPath.StartsWith(fullSourceDir + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { - response.StatusCode = (int)HttpStatusCode.RequestEntityTooLarge; + response.StatusCode = (int)HttpStatusCode.Forbidden; return; } - - if (string.IsNullOrEmpty(request.ContentType) || !request.ContentType.StartsWith("multipart/form-data", StringComparison.OrdinalIgnoreCase)) + var targetFileName = Path.GetFileName(targetPath); + if (string.IsNullOrEmpty(targetFileName)) { - response.StatusCode = (int)HttpStatusCode.UnsupportedMediaType; + var errJson = JsonConvert.SerializeObject(new { ok = false, error = "Request URL must include the target file name." }); + var errBuff = Encoding.UTF8.GetBytes(errJson); + response.ContentType = "application/json"; response.ContentLength64 = errBuff.LongLength; await response.OutputStream.WriteAsync(errBuff, 0, errBuff.Length); response.StatusCode = (int)HttpStatusCode.BadRequest; return; } + var targetDir = Path.GetDirectoryName(targetPath); + try { if (!Directory.Exists(targetDir)) Directory.CreateDirectory(targetDir); } catch { } - var saveRoot = Path.Combine(SourceDir, request.Url.LocalPath.TrimStart('/')); - try { if (!Directory.Exists(saveRoot)) Directory.CreateDirectory(saveRoot); } catch { } - var results = new List(); + // 将请求体直接流式写入目标文件,无内存缓冲,支持任意大小文件 + var dstFile = EnsureUniqueFile(targetPath); + long savedSize = 0; try { - var contents = await request.GetMultipartContent(); - //先收集所有普通字段(如 relativePath) 按 name 保存 - var formValues = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var part in contents.Where(c => c.IsFormItem)) - { - var val = part.GetAsString(Encoding.UTF8); - formValues[part.Name] = val; - } - // 对每个文件保存时如果存在 relativePath 字段则依据层级创建 - foreach (var content in contents.Where(c => c.IsFile)) + using (var fs = new FileStream(dstFile, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true)) { - var postFile = content.GetAsPostedFile(); - if (postFile == null || string.IsNullOrWhiteSpace(postFile.FileName)) continue; - var originalName = Path.GetFileName(postFile.FileName); - // 尝试从表单值中获取 relativePath,如果有则优先(可能包含目录+文件名) - var relativeKey = formValues.ContainsKey("relativePath") ? formValues["relativePath"] : originalName; - // 如果多个文件都共用一个 relativePath 会冲突,这里也尝试从文件名自身属性中获取 - if (string.IsNullOrWhiteSpace(relativeKey)) relativeKey = originalName; - //规范化路径 - // 移除上级路径标记并统一分隔符 - relativeKey = relativeKey.Replace("../", "/").Replace("..\\", "/").Replace('\\','/').TrimStart('/'); - // 提取目录部分 - var dirPart = Path.GetDirectoryName(relativeKey.Replace('/', Path.DirectorySeparatorChar)); - var targetDir = saveRoot; - if (!string.IsNullOrEmpty(dirPart)) - { - targetDir = Path.Combine(saveRoot, dirPart); - try { Directory.CreateDirectory(targetDir); } catch { } - } - var dstFile = Path.Combine(targetDir, originalName); - dstFile = EnsureUniqueFile(dstFile); - try - { - postFile.SaveAs(dstFile); - results.Add(new { name = originalName, size = postFile.ContentLength, saved = true, relativePath = relativeKey, finalPath = dstFile.Replace(SourceDir, ""), contentType = postFile.ContentType }); - } - catch (Exception ex) - { - results.Add(new { name = originalName, size = postFile.ContentLength, saved = false, relativePath = relativeKey, error = ex.Message }); - } + await request.InputStream.CopyToAsync(fs); + savedSize = fs.Length; } - - var json = JsonConvert.SerializeObject(new { ok = true, files = results }); + var json = JsonConvert.SerializeObject(new { ok = true, files = new[] { new { name = targetFileName, size = savedSize, saved = true, finalPath = dstFile.Replace(SourceDir, ""), contentType = request.ContentType } } }); var buff = Encoding.UTF8.GetBytes(json); - response.ContentType = "application/json"; response.ContentLength64 = buff.LongLength; await response.OutputStream.WriteAsync(buff,0,buff.Length); response.StatusCode = (int)HttpStatusCode.OK; + response.ContentType = "application/json"; response.ContentLength64 = buff.LongLength; await response.OutputStream.WriteAsync(buff, 0, buff.Length); response.StatusCode = (int)HttpStatusCode.OK; } catch (Exception ex) { - var json = JsonConvert.SerializeObject(new { ok=false, error=ex.Message }); var buff = Encoding.UTF8.GetBytes(json); response.ContentType="application/json"; response.ContentLength64=buff.LongLength; await response.OutputStream.WriteAsync(buff,0,buff.Length); response.StatusCode=(int)HttpStatusCode.InternalServerError; + // 删除写了一半的文件,避免留下损坏的文件 + try { if (File.Exists(dstFile)) File.Delete(dstFile); } catch { } + var json = JsonConvert.SerializeObject(new { ok = false, error = ex.Message }); var buff = Encoding.UTF8.GetBytes(json); response.ContentType = "application/json"; response.ContentLength64 = buff.LongLength; await response.OutputStream.WriteAsync(buff, 0, buff.Length); response.StatusCode = (int)HttpStatusCode.InternalServerError; } } diff --git a/src/HttpFileServer/Handlers/HttpPostHandler.cs b/src/HttpFileServer/Handlers/HttpPostHandler.cs index 38befe8..6a6443b 100644 --- a/src/HttpFileServer/Handlers/HttpPostHandler.cs +++ b/src/HttpFileServer/Handlers/HttpPostHandler.cs @@ -1,11 +1,5 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Text; +using System.Net; using System.Threading.Tasks; -using HttpFileServer.Web; namespace HttpFileServer.Handlers { @@ -35,10 +29,7 @@ public override async Task ProcessRequest(HttpListenerContext context) if (context.Request.HttpMethod.ToUpper() != "POST") return; - if (request.ContentType.StartsWith("multipart/form-data;", StringComparison.OrdinalIgnoreCase)) - await _postFileHandler.ProcessRequest(context); - else - response.StatusCode = (int)HttpStatusCode.Forbidden; + await _postFileHandler.ProcessRequest(context); } #endregion Methods diff --git a/src/HttpFileServer/HttpFileServer.csproj b/src/HttpFileServer/HttpFileServer.csproj index 0c8600e..e12f60a 100644 --- a/src/HttpFileServer/HttpFileServer.csproj +++ b/src/HttpFileServer/HttpFileServer.csproj @@ -121,6 +121,7 @@ PathSelector.xaml + diff --git a/src/HttpFileServer/Resources/UploadSection.html b/src/HttpFileServer/Resources/UploadSection.html index 275058f..db31aec 100644 --- a/src/HttpFileServer/Resources/UploadSection.html +++ b/src/HttpFileServer/Resources/UploadSection.html @@ -105,17 +105,19 @@

上传文件

this.setupEventListeners(); this.onComplete = null; } - // 修复上传:使用固定字段名 file,并附带 relativePath;进度用 xhr.upload.onprogress + // 每次只上传一个文件,将文件目标路径拼接到 POST URL 中;进度用 xhr.upload.onprogress UploadManager.prototype.simulateUpload = function(file, progressEle) { var uploadServer = this.uploadServer; return new Promise(function(resolve, reject) { - var fd = new FormData(); var rawPath = file.fullPath || file.webkitRelativePath || file.name; var relPath = rawPath.replace(/^\\/, '').replace(/^\//, ''); - fd.append('file', file, file.name); - fd.append('relativePath', relPath); + // 对路径各段单独编码后拼接到上传地址,使 POST URL 即为目标文件路径 + var encodedPath = relPath.split('/').map(function(s) { return encodeURIComponent(s); }).join('/'); + var targetUrl = uploadServer + encodedPath; var xhr = new XMLHttpRequest(); - xhr.open('POST', uploadServer, true); + xhr.open('POST', targetUrl, true); + // 直接发送文件二进制流,不使用 multipart,无文件大小限制 + xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream'); xhr.onreadystatechange = function() { if (xhr.readyState === 4) { if (xhr.status === 200) { @@ -139,7 +141,7 @@

上传文件

if (progressEle) progressEle.innerText = '上传错误'; reject(new Error('Network error')); }; - xhr.send(fd); + xhr.send(file); }); }; UploadManager.prototype.addFiles = function(files) { diff --git a/src/HttpFileServer/Views/ShellView.xaml b/src/HttpFileServer/Views/ShellView.xaml index 69bf13f..aa9eeb9 100644 --- a/src/HttpFileServer/Views/ShellView.xaml +++ b/src/HttpFileServer/Views/ShellView.xaml @@ -25,6 +25,7 @@ + @@ -600,7 +601,7 @@ Grid.Row="1" TextAlignment="Center" TextWrapping="Wrap"> - + @@ -627,7 +628,7 @@ Grid.Row="1" TextAlignment="Center" TextWrapping="Wrap"> - +