initial commit
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace lxc_songlist.Services;
|
||||
|
||||
public sealed class AdminPasswordService(IOptions<AdminAuthOptions> options)
|
||||
{
|
||||
private const int SaltBytes = 16;
|
||||
private const int HashBytes = 32;
|
||||
private const int Iterations = 210_000;
|
||||
|
||||
public bool Verify(string password)
|
||||
{
|
||||
var configuredHash = options.Value.PasswordHash;
|
||||
if (!string.IsNullOrWhiteSpace(configuredHash))
|
||||
{
|
||||
return VerifyHash(password, configuredHash);
|
||||
}
|
||||
|
||||
var configuredPassword = options.Value.Password;
|
||||
return !string.IsNullOrEmpty(configuredPassword)
|
||||
&& CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.UTF8.GetBytes(password),
|
||||
System.Text.Encoding.UTF8.GetBytes(configuredPassword));
|
||||
}
|
||||
|
||||
public static string CreateHash(string password)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashBytes);
|
||||
return $"pbkdf2${Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
private static bool VerifyHash(string password, string storedHash)
|
||||
{
|
||||
var parts = storedHash.Split('$');
|
||||
if (parts.Length != 4 || parts[0] != "pbkdf2" || !int.TryParse(parts[1], out var iterations))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var salt = Convert.FromBase64String(parts[2]);
|
||||
var expectedHash = Convert.FromBase64String(parts[3]);
|
||||
var actualHash = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, expectedHash.Length);
|
||||
return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using lxc_songlist.Data;
|
||||
using lxc_songlist.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace lxc_songlist.Services;
|
||||
|
||||
public sealed class SiteSettingsService(
|
||||
IDbContextFactory<SongListDbContext> dbFactory,
|
||||
IOptions<SiteOptions> options)
|
||||
{
|
||||
public const string RequestTemplateKey = "request_template";
|
||||
|
||||
public async Task<string> GetRequestTemplateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var setting = await db.SiteSettings.FindAsync([RequestTemplateKey], cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(setting?.Value)
|
||||
? options.Value.RequestTemplate
|
||||
: setting.Value;
|
||||
}
|
||||
|
||||
public async Task SaveRequestTemplateAsync(string template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cleanTemplate = string.IsNullOrWhiteSpace(template)
|
||||
? options.Value.RequestTemplate
|
||||
: template.Trim();
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var setting = await db.SiteSettings.FindAsync([RequestTemplateKey], cancellationToken);
|
||||
if (setting is null)
|
||||
{
|
||||
db.SiteSettings.Add(new SiteSetting
|
||||
{
|
||||
Key = RequestTemplateKey,
|
||||
Value = cleanTemplate,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
setting.Value = cleanTemplate;
|
||||
setting.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public static string FormatRequest(string template, string title)
|
||||
{
|
||||
return template.Replace("{title}", title, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using lxc_songlist.Data;
|
||||
using lxc_songlist.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace lxc_songlist.Services;
|
||||
|
||||
public sealed class SongCatalogService(IDbContextFactory<SongListDbContext> dbFactory)
|
||||
{
|
||||
public async Task<IReadOnlyList<SongListItem>> SearchSongsAsync(
|
||||
string? search,
|
||||
string? selectedTag,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queryText = TextNormalizer.Key(search ?? string.Empty);
|
||||
var tagKey = TextNormalizer.Key(selectedTag ?? string.Empty);
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var query = db.Songs
|
||||
.AsNoTracking()
|
||||
.Include(song => song.SongTags)
|
||||
.ThenInclude(songTag => songTag.Tag)
|
||||
.Where(song => !song.IsHidden);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(queryText))
|
||||
{
|
||||
query = query.Where(song =>
|
||||
song.NormalizedTitle.Contains(queryText) ||
|
||||
song.SongTags.Any(songTag => songTag.Tag.NormalizedName.Contains(queryText)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tagKey))
|
||||
{
|
||||
query = query.Where(song => song.SongTags.Any(songTag => songTag.Tag.NormalizedName == tagKey));
|
||||
}
|
||||
|
||||
var songs = await query
|
||||
.OrderBy(song => song.NormalizedTitle)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return songs.Select(ToSongListItem).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetVisibleTagsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await db.Tags
|
||||
.AsNoTracking()
|
||||
.Where(tag => tag.SongTags.Any(songTag => !songTag.Song.IsHidden))
|
||||
.OrderBy(tag => tag.NormalizedName)
|
||||
.Select(tag => tag.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddViewerTagAsync(int songId, string rawTag, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var displayTag = TextNormalizer.DisplayText(rawTag);
|
||||
var tagKey = TextNormalizer.Key(rawTag);
|
||||
|
||||
if (displayTag.Length is 0 or > 50)
|
||||
{
|
||||
throw new InvalidOperationException("标签长度需要在 1 到 50 个字符之间。");
|
||||
}
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var songExists = await db.Songs.AnyAsync(song => song.Id == songId && !song.IsHidden, cancellationToken);
|
||||
if (!songExists)
|
||||
{
|
||||
throw new InvalidOperationException("歌曲不存在或已隐藏。");
|
||||
}
|
||||
|
||||
var tag = await db.Tags.FirstOrDefaultAsync(existing => existing.NormalizedName == tagKey, cancellationToken);
|
||||
if (tag is null)
|
||||
{
|
||||
tag = new Tag { Name = displayTag, NormalizedName = tagKey, CreatedAt = DateTimeOffset.UtcNow };
|
||||
db.Tags.Add(tag);
|
||||
}
|
||||
|
||||
var alreadyLinked = await db.SongTags.AnyAsync(
|
||||
songTag => songTag.SongId == songId && songTag.Tag.NormalizedName == tagKey,
|
||||
cancellationToken);
|
||||
|
||||
if (!alreadyLinked)
|
||||
{
|
||||
db.SongTags.Add(new SongTag
|
||||
{
|
||||
SongId = songId,
|
||||
Tag = tag,
|
||||
CreatedByViewer = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdminSongItem>> GetAdminSongsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var songs = await db.Songs
|
||||
.AsNoTracking()
|
||||
.Include(song => song.SongTags)
|
||||
.ThenInclude(songTag => songTag.Tag)
|
||||
.OrderBy(song => song.NormalizedTitle)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return songs
|
||||
.Select(song => new AdminSongItem(
|
||||
song.Id,
|
||||
song.Title,
|
||||
song.IsHidden,
|
||||
song.SongTags.Select(songTag => songTag.Tag.Name).OrderBy(tag => tag).ToList()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task CreateSongAsync(string rawTitle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var title = TextNormalizer.DisplayText(rawTitle);
|
||||
var normalizedTitle = TextNormalizer.Key(title);
|
||||
if (title.Length is 0 or > 200)
|
||||
{
|
||||
throw new InvalidOperationException("歌名长度需要在 1 到 200 个字符之间。");
|
||||
}
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var exists = await db.Songs.AnyAsync(song => song.NormalizedTitle == normalizedTitle, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException("这个歌名已经存在。");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
db.Songs.Add(new Song
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = normalizedTitle,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdateSongAsync(int songId, string rawTitle, bool isHidden, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var title = TextNormalizer.DisplayText(rawTitle);
|
||||
var normalizedTitle = TextNormalizer.Key(title);
|
||||
if (title.Length is 0 or > 200)
|
||||
{
|
||||
throw new InvalidOperationException("歌名长度需要在 1 到 200 个字符之间。");
|
||||
}
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var song = await db.Songs.FindAsync([songId], cancellationToken)
|
||||
?? throw new InvalidOperationException("歌曲不存在。");
|
||||
|
||||
var duplicate = await db.Songs.AnyAsync(
|
||||
existing => existing.Id != songId && existing.NormalizedTitle == normalizedTitle,
|
||||
cancellationToken);
|
||||
if (duplicate)
|
||||
{
|
||||
throw new InvalidOperationException("这个歌名已经存在。");
|
||||
}
|
||||
|
||||
song.Title = title;
|
||||
song.NormalizedTitle = normalizedTitle;
|
||||
song.IsHidden = isHidden;
|
||||
song.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteSongAsync(int songId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var song = await db.Songs.FindAsync([songId], cancellationToken);
|
||||
if (song is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.Songs.Remove(song);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdminTagItem>> GetAdminTagsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await db.Tags
|
||||
.AsNoTracking()
|
||||
.OrderBy(tag => tag.NormalizedName)
|
||||
.Select(tag => new AdminTagItem(tag.Id, tag.Name, tag.SongTags.Count))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RenameTagAsync(int tagId, string rawName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var displayName = TextNormalizer.DisplayText(rawName);
|
||||
var tagKey = TextNormalizer.Key(rawName);
|
||||
if (displayName.Length is 0 or > 50)
|
||||
{
|
||||
throw new InvalidOperationException("标签长度需要在 1 到 50 个字符之间。");
|
||||
}
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var tag = await db.Tags.Include(existing => existing.SongTags).FirstOrDefaultAsync(existing => existing.Id == tagId, cancellationToken)
|
||||
?? throw new InvalidOperationException("标签不存在。");
|
||||
|
||||
var target = await db.Tags.Include(existing => existing.SongTags).FirstOrDefaultAsync(
|
||||
existing => existing.Id != tagId && existing.NormalizedName == tagKey,
|
||||
cancellationToken);
|
||||
|
||||
if (target is not null)
|
||||
{
|
||||
await MoveLinksAndDeleteTagAsync(db, tag, target, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
tag.Name = displayName;
|
||||
tag.NormalizedName = tagKey;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteTagAsync(int tagId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var tag = await db.Tags.FindAsync([tagId], cancellationToken);
|
||||
if (tag is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.Tags.Remove(tag);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task MergeTagAsync(int sourceTagId, string rawTargetName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var targetName = TextNormalizer.DisplayText(rawTargetName);
|
||||
var targetKey = TextNormalizer.Key(rawTargetName);
|
||||
if (targetName.Length is 0 or > 50)
|
||||
{
|
||||
throw new InvalidOperationException("目标标签长度需要在 1 到 50 个字符之间。");
|
||||
}
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var source = await db.Tags.Include(tag => tag.SongTags).FirstOrDefaultAsync(tag => tag.Id == sourceTagId, cancellationToken)
|
||||
?? throw new InvalidOperationException("来源标签不存在。");
|
||||
|
||||
var target = await db.Tags.Include(tag => tag.SongTags).FirstOrDefaultAsync(tag => tag.NormalizedName == targetKey, cancellationToken);
|
||||
if (target is null)
|
||||
{
|
||||
target = new Tag { Name = targetName, NormalizedName = targetKey, CreatedAt = DateTimeOffset.UtcNow };
|
||||
db.Tags.Add(target);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (source.Id == target.Id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await MoveLinksAndDeleteTagAsync(db, source, target, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task MoveLinksAndDeleteTagAsync(
|
||||
SongListDbContext db,
|
||||
Tag source,
|
||||
Tag target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var links = await db.SongTags.Where(link => link.TagId == source.Id).ToListAsync(cancellationToken);
|
||||
foreach (var link in links)
|
||||
{
|
||||
var targetLinkExists = await db.SongTags.AnyAsync(
|
||||
existing => existing.SongId == link.SongId && existing.TagId == target.Id,
|
||||
cancellationToken);
|
||||
|
||||
if (!targetLinkExists)
|
||||
{
|
||||
db.SongTags.Add(new SongTag
|
||||
{
|
||||
SongId = link.SongId,
|
||||
TagId = target.Id,
|
||||
CreatedByViewer = link.CreatedByViewer,
|
||||
CreatedAt = link.CreatedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
db.Tags.Remove(source);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static SongListItem ToSongListItem(Song song)
|
||||
{
|
||||
return new SongListItem(
|
||||
song.Id,
|
||||
song.Title,
|
||||
song.SongTags.Select(songTag => songTag.Tag.Name).OrderBy(tag => tag).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace lxc_songlist.Services;
|
||||
|
||||
public sealed record SongListItem(int Id, string Title, IReadOnlyList<string> Tags);
|
||||
|
||||
public sealed record AdminSongItem(int Id, string Title, bool IsHidden, IReadOnlyList<string> Tags);
|
||||
|
||||
public sealed record AdminTagItem(int Id, string Name, int SongCount);
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace lxc_songlist.Services;
|
||||
|
||||
public static partial class TextNormalizer
|
||||
{
|
||||
public static string DisplayText(string value)
|
||||
{
|
||||
return WhitespaceRegex().Replace(value.Trim(), " ");
|
||||
}
|
||||
|
||||
public static string Key(string value)
|
||||
{
|
||||
return DisplayText(value).ToLowerInvariant();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex WhitespaceRegex();
|
||||
}
|
||||
Reference in New Issue
Block a user