378 lines
14 KiB
C#
378 lines
14 KiB
C#
|
|
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";
|
|||
|
|
}
|
|||
|
|
}
|