Files
client/Services/FileService.cs
2026-05-23 18:16:50 +08:00

378 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
}
}