This commit is contained in:
2025-05-16 23:14:52 +08:00
parent 5080850523
commit c6d62a68ca
25 changed files with 543 additions and 60 deletions

View File

@@ -0,0 +1,59 @@
namespace Yes.Application.Admins.Themes
{
public record DeleteThemeCommand(string ThemeName) : IRequest<DeleteThemeCommandResponse>;
public record DeleteThemeCommandResponse();
public class DeleteThemeCommandHandler(
IWebHostEnvironment env,
BlogDbContext db,
IIdentityContext identity,
IOptionsMonitor<BlogSettings> options) : IRequestHandler<DeleteThemeCommand, DeleteThemeCommandResponse>
{
private readonly IWebHostEnvironment _env = env;
private readonly BlogSettings _settings = options.CurrentValue;
private readonly BlogDbContext _db = db;
private readonly IIdentityContext _identity = identity;
public async Task<DeleteThemeCommandResponse> Handle(DeleteThemeCommand request, CancellationToken cancellationToken)
{
var identityUser = await _db.Users.FindAsync(_identity.Id);
if (identityUser == null)
{
throw new UserNotExistsException(_identity.Id);
}
if (!identityUser.IsSystemUser())
{
throw new AccessDeniedException();
}
try
{
var themeName = request.ThemeName;
if (_settings.Theme == themeName)
{
throw new ThemeInUseException();
}
var targetDirectory = Path.Combine(_env.ContentRootPath, "files", "themes", themeName);
if (Directory.Exists(targetDirectory))
Directory.Delete(targetDirectory, recursive: true);
return new DeleteThemeCommandResponse();
}
catch (Exception ex) when (ex is OverflowException)
{
throw new FileOverflowException();
}
catch (Exception ex) when (ex is InvalidDataException)
{
throw new FileNotSupportedException(ex.Message);
}
}
}
}

View File

@@ -0,0 +1,54 @@
namespace Yes.Application.Admins.Themes
{
public record DownloadThemeCommand(string ThemeName) : IRequest<DownloadThemeCommandResponse>;
public record DownloadThemeCommandResponse(string Filepath);
public class DownloadThemeCommandHandler(
IWebHostEnvironment env,
BlogDbContext db,
IIdentityContext identity,
IOptionsMonitor<BlogSettings> options) : IRequestHandler<DownloadThemeCommand, DownloadThemeCommandResponse>
{
private readonly IWebHostEnvironment _env = env;
private readonly BlogSettings _settings = options.CurrentValue;
private readonly BlogDbContext _db = db;
private readonly IIdentityContext _identity = identity;
public async Task<DownloadThemeCommandResponse> Handle(DownloadThemeCommand request, CancellationToken cancellationToken)
{
var identityUser = await _db.Users.FindAsync(_identity.Id);
if (identityUser == null)
{
throw new UserNotExistsException(_identity.Id);
}
if (!identityUser.IsSystemUser())
{
throw new AccessDeniedException();
}
try
{
var themeName = request.ThemeName;
var targetDirectory = Path.Combine(_env.ContentRootPath, "files", "themes", themeName);
var zipPath = Path.Combine(_env.ContentRootPath, "files", "themes", $"{themeName}.zip");
ZipHelper.CompressFolderToZip(targetDirectory, zipPath);
return new DownloadThemeCommandResponse(zipPath);
}
catch (Exception ex) when (ex is OverflowException)
{
throw new FileOverflowException();
}
catch (Exception ex) when (ex is InvalidDataException)
{
throw new FileNotSupportedException(ex.Message);
}
}
}
}

View File

@@ -10,7 +10,8 @@
string Description,
string Homepage,
string DirName,
bool IsInUse
bool IsInUse,
DateTime LastWriteTime
);
public class GetThemesQueryHandler(IOptionsMonitor<BlogSettings> options, IMapper mapper, IWebHostEnvironment env) : IRequestHandler<GetThemesQuery, List<GetThemesQueryResponse>>
@@ -32,11 +33,14 @@
var jsonPath = Path.Combine(directory, "config.json");
if(!File.Exists(jsonPath)) continue;
var lastWriteTime = File.GetCreationTime(jsonPath);
var json = File.ReadAllText(jsonPath);
var theme = JsonConvert.DeserializeObject<ThemeModel>(json);
theme.DirName = dir.Name;
theme.LastWriteTime = lastWriteTime;
if (theme.DirName == _settings.Theme)
{
theme.IsInUse = true;
@@ -45,7 +49,7 @@
themes.Add(_mapper.Map<GetThemesQueryResponse>(theme));
}
themes = themes.OrderByDescending(x=>x.IsInUse).ToList();
themes = themes.OrderByDescending(x=>x.IsInUse).ThenBy(x=>x.LastWriteTime).ToList();
return themes;
}

View File

@@ -1,6 +1,6 @@
namespace Yes.Application.Admins.Themes
{
public record UploadThemeCommand(IFormFile File) : IRequest<UploadThemeCommandResponse>;
public record UploadThemeCommand(IFormFile File, string Theme) : IRequest<UploadThemeCommandResponse>;
public record UploadThemeCommandResponse(string DirName);
public class UploadThemeCommandHandler(
@@ -31,6 +31,12 @@
try
{
var file = request.File;
var theme = request.Theme;
if(string.IsNullOrWhiteSpace(theme))
{
theme = Guid.NewGuid().ToString().ToLower();
}
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var allowedExts = _settings.AllowedUploadExts;
@@ -81,14 +87,7 @@
var targetDirectory = Path.Combine(_env.ContentRootPath, "files", "themes");
ZipHelper.ExtractToDirectory(filePath, targetDirectory, newDirName);
string targetFilePath = Path.Combine(targetDirectory, newDirName, "config.json");
if (!File.Exists(targetFilePath))
{
throw new InvalidDataException("缺少config.json文件");
}
ZipHelper.ExtractAndProcessZip(filePath, targetDirectory, theme);
return new UploadThemeCommandResponse(newDirName);
}

View File

@@ -1,5 +1,4 @@
@using Yes.Domain.Core.Helpers
<footer>
<footer>
<hr>

View File

@@ -1,4 +0,0 @@
@page
@model Yes.Blog.Areas.Admin.Pages.Theme.ChangeModel
@{
}

View File

@@ -35,13 +35,21 @@
<div :class="item.isInUse ? 'panel-body bg-success' : 'panel-body'">
<img :src="`/themes/${item.dirName}/screenshot.png`" :alt="item.name" class="panel-image">
<div class="panel-content">
<h3 x-text="item.name"></h3>
<h3><span x-text="item.name"></span><span x-text="` `+item.dirName" class="small-text"></span></h3>
<p>版本:<span x-text="item.version"></span></p>
<p>作者:<span x-text="item.author"></span></p>
<p>简介:<span x-text="item.description"></span></p>
<p>主页:<span x-text="item.homepage"></span></p>
<a class="activate" href="javascript:;" x-show="!item.isInUse" x-on:click="showModal = true;this.theme=item.dirName;">切换到该主题</a>
<a class="activate" href="/admin/theme/detail" x-show="item.isInUse">自定义模板内容</a>
<p>更新日期:<span x-text="friendlyTime(item.lastWriteTime)"></span></p>
<a class="activate btn btn-default btn-sm" :href="`/admin/theme/upgrade?theme=${item.dirName}`"><i class="fa fa-upload"></i>更新主题</a>
<a class="activate btn btn-default btn-sm" href="/admin/theme/detail" x-show="item.isInUse"><i class="fa fa-code"></i>自定义模板内容</a>
<a class="activate btn btn-default btn-sm" :href="`/admin/api/themes/${item.dirName}/download`" target="_blank"><i class="fa fa-download"></i>导出</a>
<a class="activate btn btn-default btn-sm" href="javascript:;" x-show="!item.isInUse" x-on:click="this.theme=item.dirName;editTheme()"><i class="fa fa-exchange"></i>切换到该主题</a>
<a class="activate btn btn-default btn-sm" href="javascript:;" x-show="!item.isInUse" x-on:click="showModal = true;this.theme=item.dirName;"><i class="fa fa-trash"></i>删除该主题</a>
</div>
</div>
</div>
@@ -60,13 +68,13 @@
</div>
<div class="modal-body">
<p class="error-text">
<i class="fa fa-warning modal-icon"></i>确认要切换到该主题吗?
<i class="fa fa-warning modal-icon"></i>确认要删除该主题吗?
<br>操作后不可恢复.
</p>
</div>
<div class="modal-footer">
<button x-on:click="showModal = false" class="btn btn-default" data-dismiss="modal">取消</button>
<button x-on:click="showModal = false; confirmAction()" class="btn btn-danger" data-dismiss="modal">确认</button>
<button x-on:click="showModal = false; confirmDelete()" class="btn btn-danger" data-dismiss="modal">确认</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,82 @@
@page
@model Yes.Blog.Areas.Admin.Pages.Theme.UpgradeModel
@{
}
<template x-data x-if="$store.errorMessage||1">
<div class="alert alert-danger floating-div" x-show="$store.errorMessage" x-transition x-html="$store.errorMessage"></div>
</template>
<div class="header">
<div class="stats">
</div>
<h1 class="page-title">更新模板</h1>
<ul class="breadcrumb">
<li>
<a asp-page="/Index">管理中心</a>
</li>
<li>
<a asp-page="/Theme/Index">模板列表</a>
</li>
<li class="active">更新模板</li>
</ul>
</div>
<div class="main-content" x-data="data">
<div class="row">
<div class="col-md-12">
<div class="row">
<div class="col-lg-6">
<div class="form-group">
<label>选择要导入的模板文件</label>
<div id="img-container">
<template x-if="uploadFile">
<div class="img-thumb img-item">
<img class="thumb-icon" src="/admin/images/zip.png" />
<a href="javascript:;" class="img-remove" x-on:click="uploadFile='';tempfile=null">x</a>
</div>
</template>
<template x-if="!uploadFile">
<div class="img-up-add img-item">
<span class="img-add-icon" x-on:click="addFile()">+</span>
</div>
</template>
<input type="hidden" x-model="uploadFile">
<input type="file" name="files" id="img-file-input" x-model="tempfile" x-on:change="handleThemeUpload($event.target.files[0])">
</div>
</div>
</div>
</div>
<div class="btn-toolbar list-toolbar">
<button class="btn btn-primary" x-on:click="save()"><i class="fa fa-save"></i> 提交</button>
</div>
</div>
</div>
<partial name="_Footer" />
</div>
@section Scripts {
<script src="~/admin/js/qiniu.min.js" asp-append-version="true"></script>
<script src="~/admin/js/theme/upgrade.js" asp-append-version="true"></script>
}

View File

@@ -1,6 +1,6 @@
namespace Yes.Blog.Areas.Admin.Pages.Theme
{
public class ChangeModel : BasePageModel
public class UpgradeModel : BasePageModel
{
}

View File

@@ -1,3 +1,4 @@
@using Yes.Blog
@using Yes.Domain.Core.Helpers
@namespace Yes.Blog.Areas.Admin.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@@ -1,6 +1,4 @@
using Yes.Infrastructure.Authorizations;
namespace Yes.Blog.Endpoints.Admins.Auths
namespace Yes.Blog.Endpoints.Admins.Auths
{
public class UserLoginEndpoint : AuthEndpointScheme, IEndpoint
{

View File

@@ -1,6 +1,4 @@
using Yes.Infrastructure.Authorizations;
namespace Yes.Blog.Endpoints.Admins.Auths
namespace Yes.Blog.Endpoints.Admins.Auths
{
public class ValidateEndpoint : AuthEndpointScheme, IEndpoint
{

View File

@@ -0,0 +1,22 @@
namespace Yes.Blog.Endpoints.Admins.Themes
{
public class DeleteThemeEndpoint : ThemeEndpointScheme, IEndpoint
{
public void Map(IEndpointRouteBuilder app) => app.MapDelete("/themes", Handle);
public record Request([FromQuery(Name = "theme")] string ThemeName);
private async Task<IResult> Handle(
[AsParameters] Request request,
IMediator mediator, IMapper mapper, CancellationToken cancellationToken)
{
var command = new DeleteThemeCommand(request.ThemeName);
var response = await mediator.Send(command, cancellationToken);
return Result.Ok();
}
}
}

View File

@@ -0,0 +1,25 @@
namespace Yes.Blog.Endpoints.Admins.Themes
{
public class DownloadThemeEndpoint : ThemeEndpointScheme, IEndpoint
{
public void Map(IEndpointRouteBuilder app) => app.MapGet("/themes/{themeName}/download", Handle);
public record Request([FromRoute(Name = "themeName")] string ThemeName);
private async Task<Microsoft.AspNetCore.Http.IResult> Handle(
[AsParameters] Request request,
IMediator mediator, IMapper mapper, CancellationToken cancellationToken)
{
var command = new DownloadThemeCommand(request.ThemeName);
var response = await mediator.Send(command, cancellationToken);
string contentType = "application/zip";
string fileDownloadName = Path.GetFileName(response.Filepath);
return Results.File(response.Filepath, contentType, fileDownloadName);
}
}
}

View File

@@ -4,11 +4,11 @@
{
public void Map(IEndpointRouteBuilder app) => app.MapPut("/themes", Handle).WithRequestValidation<Request>();
internal record Request(
public record Request(
string Theme
);
internal class RequestValidator : AbstractValidator<Request>
public class RequestValidator : AbstractValidator<Request>
{
public RequestValidator()
{
@@ -27,7 +27,7 @@
}
}
internal class UpdateThemeMappingProfile : IRegister
public class UpdateThemeMappingProfile : IRegister
{
public void Register(TypeAdapterConfig config)
{

View File

@@ -4,10 +4,12 @@
{
public void Map(IEndpointRouteBuilder app) => app.MapPost("/themes/upload", Handle).DisableAntiforgery();
private async Task<IResult> Handle(IFormFile File, IMediator mediator, IMapper mapper, CancellationToken cancellationToken)
internal record Request(
string? Theme
);
private async Task<IResult> Handle([AsParameters] Request request, IFormFile File, IMediator mediator, IMapper mapper, CancellationToken cancellationToken)
{
var command = new UploadThemeCommand(File);
var command = new UploadThemeCommand(File, request.Theme??"");
var response = await mediator.Send(command, cancellationToken);

View File

@@ -1,6 +1,3 @@
using Hangfire;
namespace Yes.Blog
{
public class Program

View File

@@ -1065,7 +1065,7 @@ tag {
.theme-box .panel-image {
max-height: 200px;
margin: 10px 20px 10px 10px;
margin: 20px 20px 20px 10px;
}
.theme-box .panel-content {
@@ -1095,3 +1095,8 @@ tag {
font-weight: bold;
font-size: 16px;
}
.small-text {
font-size: 12px;
color: #999;
}

View File

@@ -18,7 +18,7 @@
});
},
showModal: false,
confirmAction() {
editTheme() {
this.isSubmitting = true;
axios.put('themes', { theme: theme })
.then(data => {
@@ -28,6 +28,16 @@
this.isSubmitting = false;
});
},
confirmDelete() {
this.isSubmitting = true;
axios.delete('themes?theme=' + theme)
.then(data => {
this.loadItems();
})
.finally(() => {
this.isSubmitting = false;
});
},
init() {
this.loadItems();

View File

@@ -0,0 +1,57 @@
document.addEventListener('alpine:init', function () {
Alpine.data('data', function () {
const themeName = getUrlParameter("theme") || "default";
return {
file: {
},
tagInput: "",
tempfile: null,
coverUrl: "",
files: [],
isSubmitting: false,
themeName: "",
fileName: "",
uploadFile:"",
isZipFile(filename) {
if (typeof filename !== 'string') return false;
return /\.zip$/i.test(filename);
},
handleThemeUpload(file) {
if (!this.isZipFile(file.name)) {
window.pushError("请上传zip文件");
return;
}
this.uploadFile = file;
},
save() {
const formData = new FormData();
formData.append('file', this.uploadFile);
axios.post('/themes/upload?theme=' + this.themeName, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then(data => {
location.href = "/admin/theme";
})
},
addFile() {
const fileinput = document.getElementById('img-file-input');
if (fileinput) {
fileinput.click();
}
},
init() {
this.themeName = themeName;
window.addEventListener('load', () => {
document.title = "自定义模板内容 - " + window.config.name + "";
});
}
};
})
});

View File

@@ -1,4 +1,5 @@
{
"id": "a4c8e6a8-b128-45cc-84bc-2de58f05d035",
"name": "Default Theme",
"version": "v1.0.0",
"author": "Hyrule",

View File

@@ -0,0 +1,13 @@
namespace Yes.Domain.Core.Exceptions
{
public class ThemeInUseException : BaseException
{
public ThemeInUseException() : base($"模板正在使用,不能删除!")
{
}
}
}

View File

@@ -15,5 +15,7 @@
public string DirName { get; set; }
public bool IsInUse { get; set; }
public DateTime LastWriteTime { get; set; }
}
}

View File

@@ -9,8 +9,11 @@ global using System.Net;
global using System.Runtime.Serialization;
global using System.Security.Authentication;
global using System.Security.Claims;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.RegularExpressions;
global using System.Web;
global using System.Xml.Linq;
global using Dapper;
global using FluentMigrator;
global using FluentMigrator.Runner;
@@ -47,9 +50,6 @@ global using Yes.Infrastructure.Authorizations.Identity;
global using Yes.Infrastructure.Authorizations.Identity.Authroizations;
global using Yes.Infrastructure.Authorizations.Identity.Context;
global using Yes.Infrastructure.Data.DbContexts;
global using Yes.Infrastructure.Migrator.Migrations;
global using System.Security;
global using System.Security.Cryptography;
global using Yes.Infrastructure.Helpers;
global using Microsoft.AspNetCore.Authentication.JwtBearer;
global using System.Xml.Linq;
global using Yes.Infrastructure.Migrator.Migrations;
global using static ICSharpCode.SharpZipLib.Zip.Compression.Deflater;

View File

@@ -1,7 +1,91 @@
namespace Yes.Infrastructure.Helpers

namespace Yes.Infrastructure.Helpers
{
public class ZipHelper
{
public static void ExtractAndProcessZip(string zipFilePath, string targetDirectory, string folderName)
{
#region 参数验证
if (string.IsNullOrEmpty(zipFilePath))
throw new ArgumentException("ZIP 文件路径不能为空。", nameof(zipFilePath));
if (string.IsNullOrEmpty(targetDirectory))
throw new ArgumentException("目标目录路径不能为空。", nameof(targetDirectory));
if (!File.Exists(zipFilePath))
throw new FileNotFoundException("指定的 ZIP 文件不存在。", zipFilePath);
if (!Directory.Exists(targetDirectory))
Directory.CreateDirectory(targetDirectory);
#endregion
string tempExtractPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tempExtractPath);
try
{
// 解压到临时目录
System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, tempExtractPath, overwriteFiles: true);
// 找到实际文件目录(跳过单层空文件夹)
string contentRoot = GetContentRootDirectory(tempExtractPath);
string targetFolderPath = Path.Combine(targetDirectory, folderName);
#region 创建目标文件夹并移动文件
Directory.CreateDirectory(targetFolderPath);
foreach (var file in Directory.GetFiles(contentRoot))
{
string destFile = Path.Combine(targetFolderPath, Path.GetFileName(file));
File.Move(file, destFile, true);
}
foreach (var dir in Directory.GetDirectories(contentRoot))
{
string destDir = Path.Combine(targetFolderPath, Path.GetFileName(dir));
// 若目标目录存在,先删除
if (Directory.Exists(destDir))
{
Directory.Delete(destDir, recursive: true);
}
Directory.Move(dir, destDir);
}
#endregion
}
finally
{
// 清理临时目录
if (Directory.Exists(tempExtractPath))
Directory.Delete(tempExtractPath, recursive: true);
}
}
/// <summary>
/// 递归查找实际内容目录(跳过仅包含单个文件夹的层级)
/// </summary>
private static string GetContentRootDirectory(string startPath)
{
string currentPath = startPath;
while (true)
{
string[] subDirs = Directory.GetDirectories(currentPath);
string[] files = Directory.GetFiles(currentPath);
if (files.Length > 0 || subDirs.Length == 0)
return currentPath;
if (subDirs.Length == 1)
currentPath = subDirs[0];
else
return currentPath; // 多个子目录,停止递归
}
}
public static void ExtractToDirectory(string zipPath, string targetDirectory, string themeName)
{
if (!Directory.Exists(targetDirectory))
@@ -100,24 +184,91 @@
}
private static string SanitizeEntryPath(string entryPath, string extractRoot)
{
// 路径安全处理
string fullPath = Path.GetFullPath(Path.Combine(extractRoot, entryPath));
// 验证是否在解压根目录内(防止路径遍历)
if (!fullPath.StartsWith(Path.GetFullPath(extractRoot)))
{
throw new SecurityException("非法路径访问!");
}
return fullPath;
}
private static bool IsSafeFile(string fileName)
{
var ext = Path.GetExtension(fileName).ToLower();
return new[] { ".liquid", ".js", ".css", ".html", ".md", ".ttf", ".svg", ".eot", ".woff", ".woff2", ".toml", ".xml" }.Contains(ext);
}
private static string GenerateSafeFolderName(string input, int maxLength = 200, char replaceSpaceWith = '_')
{
if (string.IsNullOrEmpty(input))
return "UnnamedFolder";
// 定义所有非法字符(Windows + Linux)
string invalidChars = Regex.Escape(new string(Path.GetInvalidFileNameChars()) + "<>:\"|?*");
Regex invalidCharRegex = new Regex($"[{invalidChars}]");
// 替换非法字符为空格
string sanitized = invalidCharRegex.Replace(input, " ");
// 替换连续空格为单个空格
sanitized = Regex.Replace(sanitized, @"\s+", " ").Trim();
// 替换空格为指定字符
sanitized = sanitized.Replace(" ", replaceSpaceWith.ToString());
// 处理保留名称(Windows)
string[] reservedNames = { "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" };
if (Array.Exists(reservedNames, name => name.Equals(sanitized, StringComparison.OrdinalIgnoreCase)))
{
sanitized += "_safe";
}
// 截断到最大长度
if (sanitized.Length > maxLength)
{
sanitized = sanitized.Substring(0, maxLength);
}
// 如果最终结果为空,返回默认值
if (string.IsNullOrEmpty(sanitized))
{
sanitized = "UnnamedFolder";
}
return sanitized;
}
/// <summary>
/// 将指定路径的文件夹打包为 ZIP 文件
/// </summary>
/// <param name="sourceFolderPath">要压缩的文件夹路径</param>
/// <param name="destinationZipPath">生成的 ZIP 文件路径</param>
/// <param name="compressionLevel">压缩级别</param>
/// <param name="overwrite">是否覆盖已存在的 ZIP 文件</param>
public static void CompressFolderToZip(
string sourceFolderPath,
string destinationZipPath,
CompressionLevel compressionLevel = CompressionLevel.BEST_SPEED,
bool overwrite = true)
{
if (string.IsNullOrEmpty(sourceFolderPath))
throw new ArgumentException("源文件夹路径不能为空。", nameof(sourceFolderPath));
if (string.IsNullOrEmpty(destinationZipPath))
throw new ArgumentException("目标 ZIP 文件路径不能为空。", nameof(destinationZipPath));
if (!Directory.Exists(sourceFolderPath))
throw new DirectoryNotFoundException($"源文件夹 '{sourceFolderPath}' 不存在。");
var destinationDirectory = Path.GetDirectoryName(destinationZipPath);
if (!Directory.Exists(destinationDirectory))
Directory.CreateDirectory(destinationDirectory);
if (File.Exists(destinationZipPath) && overwrite)
File.Delete(destinationZipPath);
try
{
System.IO.Compression.ZipFile.CreateFromDirectory(sourceFolderPath,destinationZipPath);
}
catch (Exception ex)
{
throw new IOException($"压缩文件夹失败:{ex.Message}", ex);
}
}
}
}