initial commit

This commit is contained in:
2026-06-03 20:24:57 -07:00
commit e2ad15fa64
37 changed files with 3092 additions and 0 deletions
+54
View File
@@ -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;
}
}
}
+53
View File
@@ -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);
}
}
+299
View File
@@ -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());
}
}
+7
View File
@@ -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);
+19
View File
@@ -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();
}