mirror of
https://github.com/Govor-team/Govor.git
synced 2026-07-21 19:54:55 +00:00
Add access control for media downloads
Introduced IAccesserToDownloadMedia and its implementation to enforce access checks when downloading media files. Updated MediaController to use the new accesser service and improved error handling and validation in upload/download actions. Refactored and moved MediaService to the Medias namespace, registered new services in DI, and added comprehensive tests for access logic. Also fixed GroupMembershipConfiguration to make InvitationId optional and performed minor test and namespace cleanups.
This commit is contained in:
@@ -18,6 +18,7 @@ public class MediaControllerTests
|
|||||||
private Mock<ICurrentUserService> _currentUserMock;
|
private Mock<ICurrentUserService> _currentUserMock;
|
||||||
private Mock<ILogger<MediaController>> _loggerMock;
|
private Mock<ILogger<MediaController>> _loggerMock;
|
||||||
private Mock<IMediaService> _mockMedia;
|
private Mock<IMediaService> _mockMedia;
|
||||||
|
private Mock<IAccesserToDownloadMedia> _mockAccesser;
|
||||||
private MediaController _controller;
|
private MediaController _controller;
|
||||||
private Guid _userId = Guid.NewGuid();
|
private Guid _userId = Guid.NewGuid();
|
||||||
|
|
||||||
@@ -30,11 +31,16 @@ public class MediaControllerTests
|
|||||||
|
|
||||||
_currentUserMock = new Mock<ICurrentUserService>();
|
_currentUserMock = new Mock<ICurrentUserService>();
|
||||||
_loggerMock = new Mock<ILogger<MediaController>>();
|
_loggerMock = new Mock<ILogger<MediaController>>();
|
||||||
|
_mockAccesser = new Mock<IAccesserToDownloadMedia>();
|
||||||
_mockMedia = new Mock<IMediaService>();
|
_mockMedia = new Mock<IMediaService>();
|
||||||
|
|
||||||
_currentUserMock.Setup(f => f.GetCurrentUserId()).Returns(_userId);
|
_currentUserMock.Setup(f => f.GetCurrentUserId()).Returns(_userId);
|
||||||
|
|
||||||
_controller = new MediaController(_loggerMock.Object, _mockMedia.Object, _currentUserMock.Object);
|
_controller = new MediaController(
|
||||||
|
_loggerMock.Object,
|
||||||
|
_mockMedia.Object,
|
||||||
|
_mockAccesser.Object,
|
||||||
|
_currentUserMock.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test for Upload action
|
// Test for Upload action
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
using Govor.Application.Interfaces;
|
|
||||||
using Govor.Application.Interfaces.Infrastructure.Extensions;
|
using Govor.Application.Interfaces.Infrastructure.Extensions;
|
||||||
using Govor.Application.Interfaces.Medias;
|
using Govor.Application.Interfaces.Medias;
|
||||||
using Govor.Contracts.Requests;
|
using Govor.Contracts.Requests;
|
||||||
using Govor.Core.Models;
|
|
||||||
using Govor.Core.Repositories.MediasAttachments;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@@ -16,14 +13,17 @@ public class MediaController : Controller
|
|||||||
{
|
{
|
||||||
private readonly ILogger<MediaController> _logger;
|
private readonly ILogger<MediaController> _logger;
|
||||||
private readonly IMediaService _mediaService;
|
private readonly IMediaService _mediaService;
|
||||||
|
private readonly IAccesserToDownloadMedia _accesser;
|
||||||
private readonly ICurrentUserService _currentUserService;
|
private readonly ICurrentUserService _currentUserService;
|
||||||
|
|
||||||
public MediaController(
|
public MediaController(
|
||||||
ILogger<MediaController> logger,
|
ILogger<MediaController> logger,
|
||||||
IMediaService mediaService,
|
IMediaService mediaService,
|
||||||
|
IAccesserToDownloadMedia accesser,
|
||||||
ICurrentUserService currentUserService)
|
ICurrentUserService currentUserService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_accesser = accesser;
|
||||||
_mediaService = mediaService;
|
_mediaService = mediaService;
|
||||||
_currentUserService = currentUserService;
|
_currentUserService = currentUserService;
|
||||||
}
|
}
|
||||||
@@ -32,20 +32,21 @@ public class MediaController : Controller
|
|||||||
[RequestSizeLimit(20_000_000)] // ~20MB
|
[RequestSizeLimit(20_000_000)] // ~20MB
|
||||||
public async Task<IActionResult> Upload([FromForm] MediaUploadRequest request)
|
public async Task<IActionResult> Upload([FromForm] MediaUploadRequest request)
|
||||||
{
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return BadRequest(ModelState);
|
||||||
|
|
||||||
|
if (request?.FromFile is null || request.FromFile.Length == 0)
|
||||||
|
return BadRequest("No file uploaded");
|
||||||
|
|
||||||
|
if (request.FromFile.Length > 20_000_000)
|
||||||
|
return BadRequest("File is too large");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.MimeType))
|
||||||
|
return BadRequest("Missing MIME type");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (request.FromFile.Length > 20_000_000)
|
byte[] fileBytes = await ReadFileAsync(request.FromFile);
|
||||||
return BadRequest("File is too large");
|
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
return BadRequest(ModelState);
|
|
||||||
|
|
||||||
// Чтение байт из IFormFile
|
|
||||||
using var memoryStream = new MemoryStream();
|
|
||||||
|
|
||||||
await request.FromFile.CopyToAsync(memoryStream);
|
|
||||||
|
|
||||||
byte[] fileBytes = memoryStream.ToArray();
|
|
||||||
|
|
||||||
var media = new Media(
|
var media = new Media(
|
||||||
_currentUserService.GetCurrentUserId(),
|
_currentUserService.GetCurrentUserId(),
|
||||||
@@ -59,21 +60,21 @@ public class MediaController : Controller
|
|||||||
|
|
||||||
var result = await _mediaService.UploadMediaAsync(media);
|
var result = await _mediaService.UploadMediaAsync(media);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation("Uploaded file {FileName} from user {UserId}",
|
||||||
$"Uploaded file: {Path.GetFileName(request.FromFile.FileName)} from user {_currentUserService.GetCurrentUserId()}");
|
media.FileName, media.UploaderId);
|
||||||
|
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, ex.Message);
|
|
||||||
return BadRequest(ex.Message);
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException ex)
|
catch (UnauthorizedAccessException ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, ex.Message);
|
_logger.LogWarning(ex, ex.Message);
|
||||||
return Unauthorized(ex.Message);
|
return Unauthorized(ex.Message);
|
||||||
}
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, ex.Message);
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error uploading media");
|
_logger.LogError(ex, "Error uploading media");
|
||||||
@@ -81,21 +82,32 @@ public class MediaController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<byte[]> ReadFileAsync(IFormFile file)
|
||||||
|
{
|
||||||
|
await using var ms = new MemoryStream();
|
||||||
|
await file.CopyToAsync(ms);
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[HttpGet("download/{id}")]
|
[HttpGet("download/{id}")]
|
||||||
public async Task<IActionResult> Download(Guid id)
|
public async Task<IActionResult> Download(Guid id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
var userId = _currentUserService.GetCurrentUserId();
|
||||||
return BadRequest(ModelState);
|
|
||||||
|
if (!await _accesser.HasAccessAsync(id, userId))
|
||||||
|
return Forbid();
|
||||||
|
|
||||||
var media = await _mediaService.GetMediaByIdAsync(id);
|
var media = await _mediaService.GetMediaByIdAsync(id);
|
||||||
|
|
||||||
return File(media.Data, media.MineType, Path.GetFileName(media.FileName));
|
return File(media.Data, media.MineType, Path.GetFileName(media.FileName));
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException ex)
|
catch (KeyNotFoundException ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, ex.Message);
|
_logger.LogWarning(ex, ex.Message);
|
||||||
return NotFound(ex.Message);
|
return NotFound("Media not found");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using Govor.Application.Interfaces.UserSession;
|
|||||||
using Govor.Application.Services;
|
using Govor.Application.Services;
|
||||||
using Govor.Application.Services.Authentication;
|
using Govor.Application.Services.Authentication;
|
||||||
using Govor.Application.Services.Friends;
|
using Govor.Application.Services.Friends;
|
||||||
|
using Govor.Application.Services.Medias;
|
||||||
using Govor.Application.Services.Messages;
|
using Govor.Application.Services.Messages;
|
||||||
using Govor.Application.Services.UserSessions;
|
using Govor.Application.Services.UserSessions;
|
||||||
using Govor.Core.Infrastructure.Extensions;
|
using Govor.Core.Infrastructure.Extensions;
|
||||||
@@ -68,6 +69,7 @@ public static class ConfigurationProgramExtensions
|
|||||||
services.AddScoped<IUserGroupsService, UserGroupsService>();
|
services.AddScoped<IUserGroupsService, UserGroupsService>();
|
||||||
services.AddScoped<IMessagesLoader, MessagesLoader>();
|
services.AddScoped<IMessagesLoader, MessagesLoader>();
|
||||||
services.AddScoped<IMediaService, MediaService>();
|
services.AddScoped<IMediaService, MediaService>();
|
||||||
|
services.AddScoped<IAccesserToDownloadMedia, AccesserToDownloadMediaService>();
|
||||||
|
|
||||||
// UserSession
|
// UserSession
|
||||||
services.AddScoped<IUserSessionOpener, UserSessionOpener>();
|
services.AddScoped<IUserSessionOpener, UserSessionOpener>();
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Infrastructure\Validators\" />
|
<Folder Include="Validators\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
using Govor.Application.Exceptions.AuthService;
|
using Govor.Application.Exceptions.AuthService;
|
||||||
using Govor.Application.Infrastructure.Validators;
|
using Govor.Application.Infrastructure.Validators;
|
||||||
|
|
||||||
namespace Govor.API.Tests.UnitTests.Services.Validators;
|
namespace Govor.Application.Tests.Infrastructure.Validators;
|
||||||
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class UsernameValidatorTests
|
public class UsernameValidatorTests
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using Govor.Application.Services.Medias;
|
||||||
|
using Govor.Core.Models;
|
||||||
|
using Govor.Core.Models.Messages;
|
||||||
|
using Govor.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Govor.Application.Tests.Services.Medias;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class AccesserToDownloadMediaServiceTests
|
||||||
|
{
|
||||||
|
private GovorDbContext _dbContext = null!;
|
||||||
|
private AccesserToDownloadMediaService _accesser = null!;
|
||||||
|
private Guid _userId;
|
||||||
|
private Guid _otherUserId;
|
||||||
|
private Guid _groupId;
|
||||||
|
private Guid _mediaFileId;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public async Task SetUp()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<GovorDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
_dbContext = new GovorDbContext(options);
|
||||||
|
_accesser = new AccesserToDownloadMediaService(_dbContext);
|
||||||
|
|
||||||
|
_userId = Guid.NewGuid();
|
||||||
|
_otherUserId = Guid.NewGuid();
|
||||||
|
_groupId = Guid.NewGuid();
|
||||||
|
_mediaFileId = Guid.NewGuid();
|
||||||
|
|
||||||
|
// Seed message from user to other user
|
||||||
|
var message = new Message
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
SenderId = _userId,
|
||||||
|
RecipientId = _otherUserId,
|
||||||
|
RecipientType = RecipientType.User
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
var media = new MediaFile
|
||||||
|
{
|
||||||
|
Id = _mediaFileId,
|
||||||
|
Url = "/media/test.png",
|
||||||
|
MineType = "image/png",
|
||||||
|
MediaType = MediaType.Image,
|
||||||
|
UploaderId = _userId,
|
||||||
|
DateCreated = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var attachment = new MediaAttachments
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
MediaFileId = _mediaFileId,
|
||||||
|
MessageId = message.Id,
|
||||||
|
Message = message,
|
||||||
|
MediaFile = media
|
||||||
|
};
|
||||||
|
|
||||||
|
await _dbContext.Messages.AddAsync(message);
|
||||||
|
await _dbContext.MediaFiles.AddAsync(media);
|
||||||
|
await _dbContext.MediaAttachments.AddAsync(attachment);
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task HasAccessAsync_ReturnsTrue_ForSender()
|
||||||
|
{
|
||||||
|
var result = await _accesser.HasAccessAsync(_mediaFileId, _userId);
|
||||||
|
Assert.That(result, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task HasAccessAsync_ReturnsTrue_ForRecipient()
|
||||||
|
{
|
||||||
|
var result = await _accesser.HasAccessAsync(_mediaFileId, _otherUserId);
|
||||||
|
Assert.That(result, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task HasAccessAsync_ReturnsFalse_ForUnrelatedUser()
|
||||||
|
{
|
||||||
|
var unrelatedUserId = Guid.NewGuid();
|
||||||
|
var result = await _accesser.HasAccessAsync(_mediaFileId, unrelatedUserId);
|
||||||
|
Assert.That(result, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task HasAccessAsync_ReturnsTrue_ForGroupMember()
|
||||||
|
{
|
||||||
|
var groupMediaId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var groupMessage = new Message
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
SenderId = _userId,
|
||||||
|
RecipientId = _groupId,
|
||||||
|
RecipientType = RecipientType.Group
|
||||||
|
};
|
||||||
|
|
||||||
|
var media = new MediaFile
|
||||||
|
{
|
||||||
|
Id = groupMediaId,
|
||||||
|
Url = "/media/group.png",
|
||||||
|
MineType = "image/png",
|
||||||
|
MediaType = MediaType.Image,
|
||||||
|
UploaderId = _userId,
|
||||||
|
DateCreated = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var attachment = new MediaAttachments
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
MediaFileId = groupMediaId,
|
||||||
|
MessageId = groupMessage.Id,
|
||||||
|
Message = groupMessage,
|
||||||
|
MediaFile = media
|
||||||
|
};
|
||||||
|
|
||||||
|
var membership = new GroupMembership
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
GroupId = _groupId,
|
||||||
|
UserId = _otherUserId
|
||||||
|
};
|
||||||
|
|
||||||
|
await _dbContext.Messages.AddAsync(groupMessage);
|
||||||
|
await _dbContext.MediaFiles.AddAsync(media);
|
||||||
|
await _dbContext.MediaAttachments.AddAsync(attachment);
|
||||||
|
await _dbContext.GroupMemberships.AddAsync(membership);
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _accesser.HasAccessAsync(groupMediaId, _otherUserId);
|
||||||
|
Assert.That(result, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task HasAccessAsync_ReturnsFalse_IfMediaNotAttached()
|
||||||
|
{
|
||||||
|
var result = await _accesser.HasAccessAsync(Guid.NewGuid(), _userId);
|
||||||
|
Assert.That(result, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown()
|
||||||
|
{
|
||||||
|
_dbContext.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ using Moq;
|
|||||||
using Govor.Application.Interfaces.Authentication;
|
using Govor.Application.Interfaces.Authentication;
|
||||||
using Govor.Application.Services.Authentication;
|
using Govor.Application.Services.Authentication;
|
||||||
|
|
||||||
|
namespace Govor.Application.Tests.Services.UserSessions;
|
||||||
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class UserSessionOpenerTests
|
public class UserSessionOpenerTests
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Govor.Application.Interfaces.Medias;
|
||||||
|
|
||||||
|
public interface IAccesserToDownloadMedia
|
||||||
|
{
|
||||||
|
Task<bool> HasAccessAsync(Guid mediaFileId, Guid userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Govor.Application.Interfaces.Medias;
|
||||||
|
using Govor.Core.Models.Messages;
|
||||||
|
using Govor.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Govor.Application.Services.Medias;
|
||||||
|
|
||||||
|
public class AccesserToDownloadMediaService : IAccesserToDownloadMedia
|
||||||
|
{
|
||||||
|
private readonly GovorDbContext _dbContext;
|
||||||
|
|
||||||
|
public AccesserToDownloadMediaService(GovorDbContext dbContext)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> HasAccessAsync(Guid mediaFileId, Guid userId)
|
||||||
|
{
|
||||||
|
return await _dbContext.MediaAttachments
|
||||||
|
.Include(ma => ma.Message)
|
||||||
|
.AnyAsync(ma =>
|
||||||
|
ma.MediaFileId == mediaFileId &&
|
||||||
|
(
|
||||||
|
(ma.Message.RecipientType == RecipientType.User &&
|
||||||
|
(ma.Message.SenderId == userId || ma.Message.RecipientId == userId))
|
||||||
|
||
|
||||||
|
(ma.Message.RecipientType == RecipientType.Group &&
|
||||||
|
_dbContext.GroupMemberships.Any(gm =>
|
||||||
|
gm.GroupId == ma.Message.RecipientId &&
|
||||||
|
gm.UserId == userId))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -5,7 +5,7 @@ using Govor.Data;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Govor.Application.Services.Messages;
|
namespace Govor.Application.Services.Medias;
|
||||||
|
|
||||||
public class MediaService : IMediaService
|
public class MediaService : IMediaService
|
||||||
{
|
{
|
||||||
@@ -12,7 +12,7 @@ public class GroupMembershipConfiguration : IEntityTypeConfiguration<GroupMember
|
|||||||
|
|
||||||
builder.Property(e => e.UserId).IsRequired();
|
builder.Property(e => e.UserId).IsRequired();
|
||||||
builder.Property(e => e.GroupId).IsRequired();
|
builder.Property(e => e.GroupId).IsRequired();
|
||||||
builder.Property(e => e.InvitationId).IsRequired();
|
builder.Property(e => e.InvitationId).IsRequired(false);
|
||||||
|
|
||||||
// Optional: можно добавить навигацию к GroupInvitation
|
// Optional: можно добавить навигацию к GroupInvitation
|
||||||
builder.HasOne<GroupInvitation>()
|
builder.HasOne<GroupInvitation>()
|
||||||
|
|||||||
Reference in New Issue
Block a user