Compare commits

...

3 Commits

Author SHA1 Message Date
c4f6798fd0 Able to list sticker packs with pubName 2025-06-03 23:33:06 +08:00
130ad8f186 👔 Adjust compression rate of webp uploaded 2025-06-03 00:30:35 +08:00
09e4150294 🗃️ Fix notification push subscription unique key 2025-06-03 00:30:23 +08:00
6 changed files with 3493 additions and 45 deletions

View File

@@ -27,7 +27,7 @@ public enum NotificationPushProvider
Google Google
} }
[Index(nameof(DeviceToken), nameof(DeviceId), IsUnique = true)] [Index(nameof(DeviceToken), nameof(DeviceId), nameof(AccountId), IsUnique = true)]
public class NotificationPushSubscription : ModelBase public class NotificationPushSubscription : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();

View File

@@ -36,7 +36,6 @@ public class NotificationService(
// Reset these audit fields to renew the lifecycle of this device token // Reset these audit fields to renew the lifecycle of this device token
existingSubscription.DeviceId = deviceId; existingSubscription.DeviceId = deviceId;
existingSubscription.DeviceToken = deviceToken; existingSubscription.DeviceToken = deviceToken;
existingSubscription.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(existingSubscription); db.Update(existingSubscription);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return existingSubscription; return existingSubscription;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class FixPushNotificationIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_notification_push_subscriptions_device_token_device_id",
table: "notification_push_subscriptions");
migrationBuilder.CreateIndex(
name: "ix_notification_push_subscriptions_device_token_device_id_acco",
table: "notification_push_subscriptions",
columns: new[] { "device_token", "device_id", "account_id" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_notification_push_subscriptions_device_token_device_id_acco",
table: "notification_push_subscriptions");
migrationBuilder.CreateIndex(
name: "ix_notification_push_subscriptions_device_token_device_id",
table: "notification_push_subscriptions",
columns: new[] { "device_token", "device_id" },
unique: true);
}
}
}

View File

@@ -12,18 +12,19 @@ namespace DysonNetwork.Sphere.Sticker;
[Route("/stickers")] [Route("/stickers")]
public class StickerController(AppDatabase db, StickerService st) : ControllerBase public class StickerController(AppDatabase db, StickerService st) : ControllerBase
{ {
private async Task<IActionResult> _CheckStickerPackPermissions(Guid packId, Account.Account currentUser, PublisherMemberRole requiredRole) private async Task<IActionResult> _CheckStickerPackPermissions(Guid packId, Account.Account currentUser,
PublisherMemberRole requiredRole)
{ {
var pack = await db.StickerPacks var pack = await db.StickerPacks
.Include(p => p.Publisher) .Include(p => p.Publisher)
.FirstOrDefaultAsync(p => p.Id == packId); .FirstOrDefaultAsync(p => p.Id == packId);
if (pack is null) if (pack is null)
return NotFound("Sticker pack not found"); return NotFound("Sticker pack not found");
var member = await db.PublisherMembers var member = await db.PublisherMembers
.FirstOrDefaultAsync(m => m.AccountId == currentUser.Id && m.PublisherId == pack.PublisherId); .FirstOrDefaultAsync(m => m.AccountId == currentUser.Id && m.PublisherId == pack.PublisherId);
if (member is null) if (member is null)
return StatusCode(403, "You are not a member of this publisher"); return StatusCode(403, "You are not a member of this publisher");
if (member.Role < requiredRole) if (member.Role < requiredRole)
return StatusCode(403, $"You need to be at least a {requiredRole} to perform this action"); return StatusCode(403, $"You need to be at least a {requiredRole} to perform this action");
@@ -32,11 +33,21 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa
} }
[HttpGet] [HttpGet]
public async Task<ActionResult<List<StickerPack>>> ListStickerPacks([FromQuery] int offset = 0, public async Task<ActionResult<List<StickerPack>>> ListStickerPacks(
[FromQuery] int take = 20) [FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] string? pubName = null
)
{ {
var totalCount = await db.StickerPacks.CountAsync(); Publisher.Publisher? publisher = null;
if (pubName is not null)
publisher = await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName);
var totalCount = await db.StickerPacks
.If(publisher is not null, q => q.Where(f => f.PublisherId == publisher!.Id))
.CountAsync();
var packs = await db.StickerPacks var packs = await db.StickerPacks
.If(publisher is not null, q => q.Where(f => f.PublisherId == publisher!.Id))
.OrderByDescending(e => e.CreatedAt) .OrderByDescending(e => e.CreatedAt)
.Skip(offset) .Skip(offset)
.Take(take) .Take(take)
@@ -95,22 +106,22 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(pack); return Ok(pack);
} }
[HttpPatch("{id:guid}")] [HttpPatch("{id:guid}")]
public async Task<ActionResult<StickerPack>> UpdateStickerPack(Guid id, [FromBody] StickerPackRequest request) public async Task<ActionResult<StickerPack>> UpdateStickerPack(Guid id, [FromBody] StickerPackRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized(); return Unauthorized();
var pack = await db.StickerPacks var pack = await db.StickerPacks
.Include(p => p.Publisher) .Include(p => p.Publisher)
.FirstOrDefaultAsync(p => p.Id == id); .FirstOrDefaultAsync(p => p.Id == id);
if (pack is null) if (pack is null)
return NotFound(); return NotFound();
var member = await db.PublisherMembers var member = await db.PublisherMembers
.FirstOrDefaultAsync(m => m.AccountId == currentUser.Id && m.PublisherId == pack.PublisherId); .FirstOrDefaultAsync(m => m.AccountId == currentUser.Id && m.PublisherId == pack.PublisherId);
if (member is null) if (member is null)
return StatusCode(403, "You are not a member of this publisher"); return StatusCode(403, "You are not a member of this publisher");
if (member.Role < PublisherMemberRole.Editor) if (member.Role < PublisherMemberRole.Editor)
return StatusCode(403, "You need to be at least an editor to update sticker packs"); return StatusCode(403, "You need to be at least an editor to update sticker packs");
@@ -126,22 +137,22 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(pack); return Ok(pack);
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteStickerPack(Guid id) public async Task<IActionResult> DeleteStickerPack(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized(); return Unauthorized();
var pack = await db.StickerPacks var pack = await db.StickerPacks
.Include(p => p.Publisher) .Include(p => p.Publisher)
.FirstOrDefaultAsync(p => p.Id == id); .FirstOrDefaultAsync(p => p.Id == id);
if (pack is null) if (pack is null)
return NotFound(); return NotFound();
var member = await db.PublisherMembers var member = await db.PublisherMembers
.FirstOrDefaultAsync(m => m.AccountId == currentUser.Id && m.PublisherId == pack.PublisherId); .FirstOrDefaultAsync(m => m.AccountId == currentUser.Id && m.PublisherId == pack.PublisherId);
if (member is null) if (member is null)
return StatusCode(403, "You are not a member of this publisher"); return StatusCode(403, "You are not a member of this publisher");
if (member.Role < PublisherMemberRole.Editor) if (member.Role < PublisherMemberRole.Editor)
return StatusCode(403, "You need to be an editor to delete sticker packs"); return StatusCode(403, "You need to be an editor to delete sticker packs");
@@ -162,21 +173,21 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa
return Ok(stickers); return Ok(stickers);
} }
[HttpGet("lookup/{identifier}")] [HttpGet("lookup/{identifier}")]
public async Task<ActionResult<Sticker>> GetStickerByIdentifier(string identifier) public async Task<ActionResult<Sticker>> GetStickerByIdentifier(string identifier)
{ {
var sticker = await st.LookupStickerByIdentifierAsync(identifier); var sticker = await st.LookupStickerByIdentifierAsync(identifier);
if (sticker is null) return NotFound(); if (sticker is null) return NotFound();
return Ok(sticker); return Ok(sticker);
} }
[HttpGet("lookup/{identifier}/open")] [HttpGet("lookup/{identifier}/open")]
public async Task<ActionResult<Sticker>> OpenStickerByIdentifier(string identifier) public async Task<ActionResult<Sticker>> OpenStickerByIdentifier(string identifier)
{ {
var sticker = await st.LookupStickerByIdentifierAsync(identifier); var sticker = await st.LookupStickerByIdentifierAsync(identifier);
if (sticker is null) return NotFound(); if (sticker is null) return NotFound();
return Redirect($"/files/{sticker.ImageId}"); return Redirect($"/files/{sticker.ImageId}");
} }
@@ -203,11 +214,11 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa
[HttpPatch("{packId:guid}/content/{id:guid}")] [HttpPatch("{packId:guid}/content/{id:guid}")]
public async Task<IActionResult> UpdateSticker(Guid packId, Guid id, [FromBody] StickerRequest request) public async Task<IActionResult> UpdateSticker(Guid packId, Guid id, [FromBody] StickerRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized(); return Unauthorized();
var permissionCheck = await _CheckStickerPackPermissions(packId, currentUser, PublisherMemberRole.Editor); var permissionCheck = await _CheckStickerPackPermissions(packId, currentUser, PublisherMemberRole.Editor);
if (permissionCheck is not OkResult) if (permissionCheck is not OkResult)
return permissionCheck; return permissionCheck;
var sticker = await db.Stickers var sticker = await db.Stickers
@@ -215,13 +226,13 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa
.Include(s => s.Pack) .Include(s => s.Pack)
.ThenInclude(p => p.Publisher) .ThenInclude(p => p.Publisher)
.FirstOrDefaultAsync(e => e.Id == id && e.Pack.Id == packId); .FirstOrDefaultAsync(e => e.Id == id && e.Pack.Id == packId);
if (sticker is null) if (sticker is null)
return NotFound(); return NotFound();
if (request.Slug is not null) if (request.Slug is not null)
sticker.Slug = request.Slug; sticker.Slug = request.Slug;
CloudFile? image = null; CloudFile? image = null;
if (request.ImageId is not null) if (request.ImageId is not null)
{ {
@@ -238,11 +249,11 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa
[HttpDelete("{packId:guid}/content/{id:guid}")] [HttpDelete("{packId:guid}/content/{id:guid}")]
public async Task<IActionResult> DeleteSticker(Guid packId, Guid id) public async Task<IActionResult> DeleteSticker(Guid packId, Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized(); return Unauthorized();
var permissionCheck = await _CheckStickerPackPermissions(packId, currentUser, PublisherMemberRole.Editor); var permissionCheck = await _CheckStickerPackPermissions(packId, currentUser, PublisherMemberRole.Editor);
if (permissionCheck is not OkResult) if (permissionCheck is not OkResult)
return permissionCheck; return permissionCheck;
var sticker = await db.Stickers var sticker = await db.Stickers
@@ -250,8 +261,8 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa
.Include(s => s.Pack) .Include(s => s.Pack)
.ThenInclude(p => p.Publisher) .ThenInclude(p => p.Publisher)
.FirstOrDefaultAsync(e => e.Id == id && e.Pack.Id == packId); .FirstOrDefaultAsync(e => e.Id == id && e.Pack.Id == packId);
if (sticker is null) if (sticker is null)
return NotFound(); return NotFound();
await st.DeleteStickerAsync(sticker); await st.DeleteStickerAsync(sticker);
@@ -264,30 +275,30 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa
[RequiredPermission("global", "stickers.create")] [RequiredPermission("global", "stickers.create")]
public async Task<IActionResult> CreateSticker(Guid packId, [FromBody] StickerRequest request) public async Task<IActionResult> CreateSticker(Guid packId, [FromBody] StickerRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized(); return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Slug)) if (string.IsNullOrWhiteSpace(request.Slug))
return BadRequest("Slug is required."); return BadRequest("Slug is required.");
if (request.ImageId is null) if (request.ImageId is null)
return BadRequest("Image is required."); return BadRequest("Image is required.");
var permissionCheck = await _CheckStickerPackPermissions(packId, currentUser, PublisherMemberRole.Editor); var permissionCheck = await _CheckStickerPackPermissions(packId, currentUser, PublisherMemberRole.Editor);
if (permissionCheck is not OkResult) if (permissionCheck is not OkResult)
return permissionCheck; return permissionCheck;
var pack = await db.StickerPacks var pack = await db.StickerPacks
.Include(p => p.Publisher) .Include(p => p.Publisher)
.FirstOrDefaultAsync(e => e.Id == packId); .FirstOrDefaultAsync(e => e.Id == packId);
if (pack is null) if (pack is null)
return BadRequest("Sticker pack was not found."); return BadRequest("Sticker pack was not found.");
var stickersCount = await db.Stickers.CountAsync(s => s.PackId == packId); var stickersCount = await db.Stickers.CountAsync(s => s.PackId == packId);
if (stickersCount >= MaxStickersPerPack) if (stickersCount >= MaxStickersPerPack)
return BadRequest($"Sticker pack has reached maximum capacity of {MaxStickersPerPack} stickers."); return BadRequest($"Sticker pack has reached maximum capacity of {MaxStickersPerPack} stickers.");
var image = await db.Files.FirstOrDefaultAsync(e => e.Id == request.ImageId); var image = await db.Files.FirstOrDefaultAsync(e => e.Id == request.ImageId);
if (image is null) if (image is null)
return BadRequest("Image was not found."); return BadRequest("Image was not found.");
var sticker = new Sticker var sticker = new Sticker

View File

@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Minio; using Minio;
using Minio.DataModel.Args; using Minio.DataModel.Args;
using NetVips;
using NodaTime; using NodaTime;
using Quartz; using Quartz;
using tusdotnet.Stores; using tusdotnet.Stores;
@@ -51,6 +52,7 @@ public class FileService(
} }
private static readonly string TempFilePrefix = "dyn-cloudfile"; private static readonly string TempFilePrefix = "dyn-cloudfile";
private static readonly string[] function = new[] { "image/gif", "image/apng", "image/webp", "image/avif" };
// The analysis file method no longer will remove the GPS EXIF data // The analysis file method no longer will remove the GPS EXIF data
// It should be handled on the client side, and for some specific cases it should be keep // It should be handled on the client side, and for some specific cases it should be keep
@@ -110,7 +112,7 @@ public class FileService(
var format = vipsImage.Get("vips-loader") ?? "unknown"; var format = vipsImage.Get("vips-loader") ?? "unknown";
// Try to get orientation from exif data // Try to get orientation from exif data
int orientation = 1; var orientation = 1;
Dictionary<string, object> exif = []; Dictionary<string, object> exif = [];
foreach (var field in vipsImage.GetFields()) foreach (var field in vipsImage.GetFields())
@@ -177,10 +179,12 @@ public class FileService(
if (contentType.Split('/')[0] == "image") if (contentType.Split('/')[0] == "image")
{ {
// Skip compression for animated image types // Skip compression for animated image types
var animatedMimeTypes = new[] { "image/gif", "image/apng", "image/webp", "image/avif" }; var animatedMimeTypes = function;
if (animatedMimeTypes.Contains(contentType)) if (animatedMimeTypes.Contains(contentType))
{ {
logger.LogInformation("File {fileId} is an animated image (MIME: {mime}), skipping WebP conversion.", fileId, contentType); logger.LogInformation(
"File {fileId} is an animated image (MIME: {mime}), skipping WebP conversion.", fileId,
contentType);
var tempFilePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}"); var tempFilePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}");
result.Add((tempFilePath, string.Empty)); result.Add((tempFilePath, string.Empty));
return; return;
@@ -190,7 +194,8 @@ public class FileService(
using var vipsImage = NetVips.Image.NewFromFile(ogFilePath); using var vipsImage = NetVips.Image.NewFromFile(ogFilePath);
var imagePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}"); var imagePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}");
vipsImage.WriteToFile(imagePath + ".webp"); vipsImage.WriteToFile(imagePath + ".webp",
new VOption { { "lossless", true } });
result.Add((imagePath + ".webp", string.Empty)); result.Add((imagePath + ".webp", string.Empty));
if (vipsImage.Width * vipsImage.Height >= 1024 * 1024) if (vipsImage.Width * vipsImage.Height >= 1024 * 1024)
@@ -201,7 +206,8 @@ public class FileService(
// Create and save image within the same synchronous block to avoid disposal issues // Create and save image within the same synchronous block to avoid disposal issues
using var compressedImage = vipsImage.Resize(scale); using var compressedImage = vipsImage.Resize(scale);
compressedImage.WriteToFile(imageCompressedPath + ".webp"); compressedImage.WriteToFile(imageCompressedPath + ".webp",
new VOption { { "Q", 80 } });
result.Add((imageCompressedPath + ".webp", ".compressed")); result.Add((imageCompressedPath + ".webp", ".compressed"));
file.HasCompression = true; file.HasCompression = true;
@@ -408,7 +414,7 @@ public class FileService(
return client.Build(); return client.Build();
} }
// Helper method to purge the cache for a specific file // Helper method to purge the cache for a specific file
// Made internal to allow FileReferenceService to use it // Made internal to allow FileReferenceService to use it
internal async Task _PurgeCacheAsync(string fileId) internal async Task _PurgeCacheAsync(string fileId)
@@ -492,4 +498,4 @@ public class FileService(
.Where(r => r.FileId == fileId) .Where(r => r.FileId == fileId)
.AnyAsync(); .AnyAsync();
} }
} }