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; /// /// 文件处理服务:校验白名单、管理本地文库副本、解压压缩包、转换 Office/TXT 并处理 PDF 页码范围。 /// 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 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 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 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 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 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 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"; } }