Files
client/Services/FileService.cs

378 lines
14 KiB
C#
Raw Normal View History

2026-05-23 18:16:50 +08:00
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using CloudPrint.Client.Helpers;
using CloudPrint.Client.Models;
using CloudPrint.Client.Services.Abstractions;
using SharpCompress.Common;
using SharpCompress.Readers;
namespace CloudPrint.Client.Services;
/// <summary>
/// 文件处理服务:校验白名单、管理本地文库副本、解压压缩包、转换 Office/TXT 并处理 PDF 页码范围。
/// </summary>
public sealed class FileService : IFileService
{
private const int MaxArchiveEntries = 512;
private const int MaxArchiveNestingDepth = 3;
private const long MaxExtractedFileSizeBytes = 200L * 1024 * 1024;
private static readonly HashSet<string> SupportedExtensionSet = new(StringComparer.OrdinalIgnoreCase)
{
".pdf", ".doc", ".docx", ".txt", ".xls", ".xlsx", ".ppt", ".pptx",
".jpg", ".jpeg", ".png", ".bmp", ".gif", ".zip", ".rar"
};
private readonly PdfProcessor _pdfProcessor;
public FileService()
: this(new PdfProcessor())
{
}
public FileService(PdfProcessor pdfProcessor)
{
_pdfProcessor = pdfProcessor;
}
public long MaxFileSizeBytes { get; } = 50L * 1024 * 1024;
public IReadOnlyCollection<string> SupportedExtensions => SupportedExtensionSet;
public bool IsSupported(string path)
{
return SupportedExtensionSet.Contains(Path.GetExtension(path));
}
public FileValidationResult ValidateFile(string path, long? maxFileSizeBytes = null)
{
if (string.IsNullOrWhiteSpace(path))
{
return FileValidationResult.Failure("文件路径不能为空");
}
if (!File.Exists(path))
{
return FileValidationResult.Failure("文件不存在");
}
if (!IsSupported(path))
{
return FileValidationResult.Failure($"不支持的文件类型:{Path.GetExtension(path)}");
}
var fileInfo = new FileInfo(path);
var limit = maxFileSizeBytes ?? MaxFileSizeBytes;
if (fileInfo.Length > limit)
{
return FileValidationResult.Failure($"文件超过大小限制:{FormatSize(limit)}");
}
return FileValidationResult.Success();
}
public CloudPrintDocument CreateLocalDocument(string path)
{
var validation = ValidateFile(path);
if (!validation.IsValid)
{
throw new InvalidOperationException(validation.Message);
}
var fileInfo = new FileInfo(path);
return new CloudPrintDocument
{
Id = Environment.TickCount,
FileName = fileInfo.Name,
FilePath = fileInfo.FullName,
FileType = fileInfo.Extension.TrimStart('.').ToLowerInvariant(),
FileSize = fileInfo.Length,
Title = Path.GetFileNameWithoutExtension(fileInfo.Name),
Description = "本地文库待上传文件",
CategoryName = "未分类",
CreatedAt = DateTime.Now
};
}
public CloudPrintDocument CopyToLibrary(string sourcePath, string libraryRoot, string categoryName, int categoryId = 0)
{
var document = CreateLocalDocument(sourcePath);
var safeCategoryName = PathHelper.SanitizePathSegment(string.IsNullOrWhiteSpace(categoryName) ? "未分类" : categoryName);
var root = string.IsNullOrWhiteSpace(libraryRoot)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "CloudPrintLibrary")
: libraryRoot;
var targetDirectory = Path.Combine(root, safeCategoryName);
Directory.CreateDirectory(targetDirectory);
var targetPath = PathHelper.CreateUniqueFilePath(targetDirectory, document.FileName);
File.Copy(sourcePath, targetPath);
var copiedFileInfo = new FileInfo(targetPath);
return new CloudPrintDocument
{
Id = Environment.TickCount,
FileName = copiedFileInfo.Name,
FilePath = copiedFileInfo.FullName,
FileType = copiedFileInfo.Extension.TrimStart('.').ToLowerInvariant(),
FileSize = copiedFileInfo.Length,
Title = document.Title,
Description = document.Description,
CategoryName = categoryName,
CategoryId = categoryId,
SyncStatus = "本地",
CreatedAt = DateTime.Now
};
}
public string PrepareForPrint(PrintJob job)
{
ArgumentNullException.ThrowIfNull(job);
if (string.IsNullOrWhiteSpace(job.FilePath))
{
return string.Empty;
}
var extension = Path.GetExtension(job.FilePath).ToLowerInvariant();
return extension switch
{
".pdf" => job.FilePath,
".jpg" or ".jpeg" or ".png" or ".bmp" or ".gif" => job.FilePath,
".doc" or ".docx" or ".xls" or ".xlsx" or ".ppt" or ".pptx" or ".txt" => job.FilePath,
".zip" or ".rar" => job.FilePath,
_ => job.FilePath
};
}
public async Task<FilePreparationResult> PrepareForPrintAsync(PrintJob job, string cacheRoot, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
if (string.IsNullOrWhiteSpace(job.FilePath))
{
throw new InvalidOperationException("打印任务缺少文件路径");
}
if (!File.Exists(job.FilePath))
{
throw new FileNotFoundException("打印文件不存在", job.FilePath);
}
var root = string.IsNullOrWhiteSpace(cacheRoot)
? Path.Combine(Path.GetTempPath(), "CloudPrint.Client", "prepared")
: cacheRoot;
Directory.CreateDirectory(root);
return await PreparePathAsync(job.FilePath, root, job.PageRange, 0, cancellationToken).ConfigureAwait(true);
}
private async Task<FilePreparationResult> PreparePathAsync(string sourcePath, string root, string pageRange, int archiveDepth, CancellationToken cancellationToken)
{
var extension = Path.GetExtension(sourcePath).ToLowerInvariant();
if (IsPdf(extension))
{
return await PreparePdfAsync(sourcePath, root, pageRange, cancellationToken).ConfigureAwait(true);
}
if (IsImage(extension))
{
return new FilePreparationResult
{
SourcePath = sourcePath,
PreparedPath = sourcePath,
Converted = false,
Message = "文件无需转换"
};
}
if (extension is ".zip" or ".rar")
{
if (archiveDepth >= MaxArchiveNestingDepth)
{
throw new InvalidOperationException($"压缩包嵌套层级过深,最多允许 {MaxArchiveNestingDepth} 层");
}
var extractedPath = ExtractFirstPrintableFileFromArchive(sourcePath, root);
return await PreparePathAsync(extractedPath, root, pageRange, archiveDepth + 1, cancellationToken).ConfigureAwait(true);
}
if (IsOfficeOrText(extension))
{
var pdfPath = await ConvertToPdfWithLibreOfficeAsync(sourcePath, root, cancellationToken).ConfigureAwait(true);
var preparedPdf = await PreparePdfAsync(pdfPath, root, pageRange, cancellationToken).ConfigureAwait(true);
return new FilePreparationResult
{
SourcePath = sourcePath,
PreparedPath = preparedPdf.PreparedPath,
Converted = true,
Message = preparedPdf.PreparedPath == pdfPath ? "已转换为 PDF" : "已转换为 PDF 并裁剪页码范围"
};
}
throw new NotSupportedException($"不支持的打印文件类型:{extension}");
}
private async Task<FilePreparationResult> PreparePdfAsync(string pdfPath, string root, string pageRange, CancellationToken cancellationToken)
{
var outputDirectory = Path.Combine(root, "pdf-pages", DateTime.Now.ToString("yyyyMMddHHmmssfff"));
var preparedPath = await _pdfProcessor.ExtractPageRangeAsync(pdfPath, pageRange, outputDirectory, cancellationToken).ConfigureAwait(true);
return new FilePreparationResult
{
SourcePath = pdfPath,
PreparedPath = preparedPath,
Converted = preparedPath != pdfPath,
Message = preparedPath == pdfPath ? "PDF 文件无需转换" : $"已按页码范围裁剪:{pageRange}"
};
}
private string ExtractFirstPrintableFileFromArchive(string archivePath, string root)
{
var extractDirectory = Path.Combine(root, PathHelper.SanitizePathSegment(Path.GetFileNameWithoutExtension(archivePath)), DateTime.Now.ToString("yyyyMMddHHmmssfff"));
Directory.CreateDirectory(extractDirectory);
using var stream = File.OpenRead(archivePath);
using var reader = ReaderFactory.OpenReader(stream);
var entryCount = 0;
while (reader.MoveToNextEntry())
{
if (reader.Entry.IsDirectory)
{
continue;
}
entryCount++;
if (entryCount > MaxArchiveEntries)
{
throw new InvalidOperationException($"压缩包条目过多,最多允许 {MaxArchiveEntries} 个文件");
}
if (!IsPrintableArchiveEntry(reader.Entry))
{
continue;
}
if (reader.Entry.Size > MaxExtractedFileSizeBytes)
{
throw new InvalidOperationException($"压缩包内文件超过解压大小限制:{FormatSize(MaxExtractedFileSizeBytes)}");
}
var targetPath = CreateSafeExtractPath(extractDirectory, reader.Entry.Key);
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? extractDirectory);
using var input = reader.OpenEntryStream();
using var output = File.Create(targetPath);
input.CopyTo(output);
return targetPath;
}
throw new InvalidOperationException("压缩包中没有可打印文件");
}
private static string CreateSafeExtractPath(string extractDirectory, string? entryKey)
{
var normalizedKey = string.IsNullOrWhiteSpace(entryKey)
? Guid.NewGuid().ToString("N")
: entryKey.Replace('\\', '/');
var safeSegments = normalizedKey
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Where(segment => segment != "." && segment != "..")
.Select(PathHelper.SanitizePathSegment)
.Where(segment => !string.IsNullOrWhiteSpace(segment))
.ToArray();
if (safeSegments.Length == 0)
{
safeSegments = new[] { Guid.NewGuid().ToString("N") };
}
var targetPath = Path.GetFullPath(Path.Combine(new[] { extractDirectory }.Concat(safeSegments).ToArray()));
var safeRoot = Path.GetFullPath(extractDirectory) + Path.DirectorySeparatorChar;
if (!targetPath.StartsWith(safeRoot, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("压缩包包含不安全的文件路径");
}
return targetPath;
}
private static bool IsPrintableArchiveEntry(IEntry entry)
{
var extension = Path.GetExtension(entry.Key ?? string.Empty).ToLowerInvariant();
return SupportedExtensionSet.Contains(extension);
}
private static async Task<string> ConvertToPdfWithLibreOfficeAsync(string sourcePath, string outputRoot, CancellationToken cancellationToken)
{
var sofficePath = FindLibreOfficeExecutable();
if (string.IsNullOrWhiteSpace(sofficePath))
{
throw new FileNotFoundException("未找到 LibreOffice soffice.exe无法转换 Office/TXT 文件");
}
var outputDirectory = Path.Combine(outputRoot, "converted", DateTime.Now.ToString("yyyyMMddHHmmssfff"));
Directory.CreateDirectory(outputDirectory);
var startInfo = new ProcessStartInfo
{
FileName = sofficePath,
Arguments = $"--headless --convert-to pdf --outdir \"{outputDirectory}\" \"{sourcePath}\"",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true
};
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("启动 LibreOffice 失败");
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(true);
if (process.ExitCode != 0)
{
var error = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(true);
throw new InvalidOperationException($"LibreOffice 转换失败:{error}");
}
var expectedPdfPath = Path.Combine(outputDirectory, Path.ChangeExtension(Path.GetFileName(sourcePath), ".pdf"));
if (File.Exists(expectedPdfPath))
{
return expectedPdfPath;
}
var generatedPdf = Directory.EnumerateFiles(outputDirectory, "*.pdf", SearchOption.TopDirectoryOnly).FirstOrDefault();
return generatedPdf ?? throw new FileNotFoundException("LibreOffice 未生成 PDF 文件");
}
private static string? FindLibreOfficeExecutable()
{
var candidates = new[]
{
Environment.GetEnvironmentVariable("SOFFICE_PATH"),
@"C:\Program Files\LibreOffice\program\soffice.exe",
@"C:\Program Files (x86)\LibreOffice\program\soffice.exe"
};
return candidates.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path));
}
private static bool IsPdf(string extension) => extension == ".pdf";
private static bool IsImage(string extension)
{
return extension is ".jpg" or ".jpeg" or ".png" or ".bmp" or ".gif";
}
private static bool IsOfficeOrText(string extension)
{
return extension is ".doc" or ".docx" or ".xls" or ".xlsx" or ".ppt" or ".pptx" or ".txt";
}
private static string FormatSize(long bytes)
{
return bytes >= 1024 * 1024
? $"{bytes / 1024d / 1024d:F2} MB"
: $"{bytes / 1024d:F2} KB";
}
}