Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/HttpFileServer/Converters/StringToUriConverter.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
17 changes: 15 additions & 2 deletions src/HttpFileServer/Handlers/HttpGetHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ protected async Task<Tuple<string, Stream, bool>> 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
{
Expand All @@ -274,8 +275,20 @@ protected async Task<Tuple<string, Stream, bool>> GetResponseContentTypeAndStrea
// Invalid path characters or other path resolution errors
return new Tuple<string, Stream, bool>(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<string, Stream, bool>(contentType, null, false);
Expand Down
80 changes: 26 additions & 54 deletions src/HttpFileServer/Handlers/HttpPostFileHandler.cs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<object>();
// 将请求体直接流式写入目标文件,无内存缓冲,支持任意大小文件
var dstFile = EnsureUniqueFile(targetPath);
long savedSize = 0;
try
{
var contents = await request.GetMultipartContent();
//先收集所有普通字段(如 relativePath) 按 name 保存
var formValues = new Dictionary<string, string>(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;
}
}

Expand Down
13 changes: 2 additions & 11 deletions src/HttpFileServer/Handlers/HttpPostHandler.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/HttpFileServer/HttpFileServer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
<DependentUpon>PathSelector.xaml</DependentUpon>
</Compile>
<Compile Include="Converters\BoolReverseConverter.cs" />
<Compile Include="Converters\StringToUriConverter.cs" />
<Compile Include="Converters\SubtractConverter.cs" />
<Compile Include="Core\Config.cs" />
<Compile Include="Core\DirInfoResponse.cs" />
Expand Down
14 changes: 8 additions & 6 deletions src/HttpFileServer/Resources/UploadSection.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,19 @@ <h3 class="text-sm font-semibold text-primary">上传文件</h3>
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) {
Expand All @@ -139,7 +141,7 @@ <h3 class="text-sm font-semibold text-primary">上传文件</h3>
if (progressEle) progressEle.innerText = '上传错误';
reject(new Error('Network error'));
};
xhr.send(fd);
xhr.send(file);
});
};
UploadManager.prototype.addFiles = function(files) {
Expand Down
5 changes: 3 additions & 2 deletions src/HttpFileServer/Views/ShellView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<Window.Resources>
<converters:BoolReverseConverter x:Key="boolReverse" />
<converters:SubtractConverter x:Key="subtractConverter" />
<converters:StringToUriConverter x:Key="stringToUri" />
<!-- 初始主题色(浅色),ViewModel 动态替换 -->
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="WindowForegroundBrush" Color="#111111" />
Expand Down Expand Up @@ -600,7 +601,7 @@
Grid.Row="1"
TextAlignment="Center"
TextWrapping="Wrap">
<Hyperlink NavigateUri="{Binding IPv4Text}" RequestNavigate="Hyperlink_RequestNavigate">
<Hyperlink NavigateUri="{Binding IPv4Text, Converter={StaticResource stringToUri}}" RequestNavigate="Hyperlink_RequestNavigate">
<Run Text="{Binding IPv4Text, Mode=OneWay}" />
</Hyperlink>
</TextBlock>
Expand All @@ -627,7 +628,7 @@
Grid.Row="1"
TextAlignment="Center"
TextWrapping="Wrap">
<Hyperlink NavigateUri="{Binding IPv6Text}" RequestNavigate="Hyperlink_RequestNavigate">
<Hyperlink NavigateUri="{Binding IPv6Text, Converter={StaticResource stringToUri}}" RequestNavigate="Hyperlink_RequestNavigate">
<Run Text="{Binding IPv6Text, Mode=OneWay}" />
</Hyperlink>
</TextBlock>
Expand Down