Add logout and image upload functionality
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/media")]
|
||||
public class MediaApi : ControllerBase
|
||||
{
|
||||
private readonly MediaService _mediaService;
|
||||
|
||||
public MediaApi(MediaService mediaService)
|
||||
{
|
||||
_mediaService = mediaService;
|
||||
}
|
||||
|
||||
[HttpGet("load")]
|
||||
public async Task<IActionResult> LoadMedia([FromQuery] int offset = 0, [FromQuery] int limit = 20)
|
||||
{
|
||||
// Get user ID from session
|
||||
var userIdString = HttpContext.Session.GetString("UserId");
|
||||
if (string.IsNullOrEmpty(userIdString))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var userId = long.Parse(userIdString);
|
||||
var media = await _mediaService.GetUserMediaAsync(userId, offset, limit);
|
||||
|
||||
return Ok(media);
|
||||
}
|
||||
|
||||
[HttpGet("image/{mediaId}")]
|
||||
public async Task<IActionResult> GetImage(long mediaId)
|
||||
{
|
||||
// Get user ID from session
|
||||
var userIdString = HttpContext.Session.GetString("UserId");
|
||||
if (string.IsNullOrEmpty(userIdString))
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var userId = long.Parse(userIdString);
|
||||
|
||||
// Get media metadata (includes ownership check)
|
||||
var media = await _mediaService.GetMediaByIdAsync(mediaId, userId);
|
||||
if (media == null)
|
||||
{
|
||||
return NotFound(new { error = "Image not found or access denied" });
|
||||
}
|
||||
|
||||
// Get decrypted image data
|
||||
var imageData = await _mediaService.GetDecryptedMediaDataAsync(mediaId, userId);
|
||||
if (imageData == null)
|
||||
{
|
||||
return NotFound(new { error = "Image file not found" });
|
||||
}
|
||||
|
||||
// Return image with proper content type
|
||||
return File(imageData, media.MimeType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- User Media/Images Table
|
||||
-- Stores encrypted images outside wwwroot for security
|
||||
CREATE TABLE IF NOT EXISTS app.user_media (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_path VARCHAR(500) NOT NULL, -- Path to encrypted file outside wwwroot
|
||||
file_size BIGINT NOT NULL, -- Original file size before encryption
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
width INT NULL,
|
||||
height INT NULL,
|
||||
description TEXT NULL,
|
||||
is_encrypted BOOLEAN DEFAULT true, -- Flag indicating if file is encrypted on disk
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_media_user_id ON app.user_media(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_media_created_at ON app.user_media(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_media_mime_type ON app.user_media(mime_type);
|
||||
@@ -78,4 +78,29 @@ public class DbExecutor(IConfiguration config)
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
// Returns a list using custom mapping
|
||||
public async Task<List<T>> ExecuteListReaderAsync<T>(string query, Func<NpgsqlDataReader, T> mapper, object? parameters = null)
|
||||
{
|
||||
parameters ??= new();
|
||||
using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
using var cmd = new NpgsqlCommand(query, conn);
|
||||
|
||||
foreach (var prop in parameters.GetType().GetProperties())
|
||||
{
|
||||
cmd.Parameters.AddWithValue($"@{prop.Name}", prop.GetValue(parameters) ?? DBNull.Value);
|
||||
}
|
||||
|
||||
using var reader = await cmd.ExecuteReaderAsync();
|
||||
|
||||
var results = new List<T>();
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
results.Add(mapper(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="MailKit" Version="4.8.0" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.4" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class UserMedia
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
public string FilePath { get; set; } = string.Empty; // Path to encrypted file on disk
|
||||
public long FileSize { get; set; }
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
public int? Width { get; set; }
|
||||
public int? Height { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool IsEncrypted { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/Home/page.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/gallery.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<div class="dashboard-container">
|
||||
@@ -102,4 +103,57 @@
|
||||
<a href="/Logout" class="btn btn-danger">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Upload Image</h2>
|
||||
@if (!string.IsNullOrEmpty(Model.UploadMessage))
|
||||
{
|
||||
<div class="alert @(Model.UploadSuccess ? "alert-success" : "alert-error")">
|
||||
@Model.UploadMessage
|
||||
</div>
|
||||
}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="form-group">
|
||||
<label for="UploadedFile">Select Image</label>
|
||||
<input type="file" id="UploadedFile" name="UploadedFile" accept="image/*" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="Description">Description (optional)</label>
|
||||
<textarea id="Description" name="Description" rows="3" placeholder="Add a description..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>My Images (@Model.MediaItems.Count)</h2>
|
||||
<div id="gallery" class="gallery-grid">
|
||||
@foreach (var media in Model.MediaItems)
|
||||
{
|
||||
<div class="gallery-item" data-media-id="@media.Id">
|
||||
<img src="/api/media/image/@media.Id" alt="@media.FileName" loading="lazy" />
|
||||
@if (!string.IsNullOrEmpty(media.Description))
|
||||
{
|
||||
<div class="gallery-item-description">@media.Description</div>
|
||||
}
|
||||
<div class="gallery-item-info">
|
||||
<span class="gallery-item-date">@media.CreatedAt.ToString("MMM d, yyyy")</span>
|
||||
<form method="post" asp-page-handler="Delete" style="display:inline;">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="mediaId" value="@media.Id" />
|
||||
<button type="submit" class="btn-delete" onclick="return confirm('Are you sure you want to delete this image?');">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div id="loading" style="display:none; text-align:center; padding:20px;">
|
||||
Loading more images...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/gallery.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -4,29 +4,92 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages
|
||||
{
|
||||
public class IndexModel(UserService userService) : AuthenticatedPageModel
|
||||
public class IndexModel(UserService userService, MediaService mediaService) : AuthenticatedPageModel
|
||||
{
|
||||
private readonly MediaService _mediaService = mediaService;
|
||||
|
||||
public UserDashboard? Dashboard { get; set; }
|
||||
public List<UserMedia> MediaItems { get; set; } = new();
|
||||
|
||||
[BindProperty]
|
||||
public IFormFile? UploadedFile { get; set; }
|
||||
|
||||
[BindProperty]
|
||||
public string? Description { get; set; }
|
||||
|
||||
public string? UploadMessage { get; set; }
|
||||
public bool UploadSuccess { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
RequireAuthentication();
|
||||
|
||||
LoadUserSession();
|
||||
|
||||
// Load user dashboard data
|
||||
Dashboard = await userService.GetUserDashboardAsync(UserId);
|
||||
|
||||
// Load initial set of media
|
||||
MediaItems = await _mediaService.GetUserMediaAsync(UserId, 0, 20);
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
RequireAuthentication();
|
||||
|
||||
LoadUserSession();
|
||||
|
||||
if (UploadedFile == null || UploadedFile.Length == 0)
|
||||
{
|
||||
UploadMessage = "Please select a file to upload.";
|
||||
UploadSuccess = false;
|
||||
}
|
||||
else if (!IsValidImageFile(UploadedFile))
|
||||
{
|
||||
UploadMessage = "Invalid file type. Only images (JPG, PNG, GIF, WEBP) are allowed.";
|
||||
UploadSuccess = false;
|
||||
}
|
||||
else if (UploadedFile.Length > 10 * 1024 * 1024) // 10MB limit
|
||||
{
|
||||
UploadMessage = "File size exceeds 10MB limit.";
|
||||
UploadSuccess = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
var media = await _mediaService.SaveMediaAsync(UserId, UploadedFile, Description);
|
||||
if (media != null)
|
||||
{
|
||||
UploadMessage = "Image uploaded successfully!";
|
||||
UploadSuccess = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
UploadMessage = "Failed to upload image. Please try again.";
|
||||
UploadSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reload data
|
||||
Dashboard = await userService.GetUserDashboardAsync(UserId);
|
||||
MediaItems = await _mediaService.GetUserMediaAsync(UserId, 0, 20);
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostDeleteAsync(long mediaId)
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
var success = await _mediaService.DeleteMediaAsync(mediaId, UserId);
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
private bool IsValidImageFile(IFormFile file)
|
||||
{
|
||||
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" };
|
||||
return allowedTypes.Contains(file.ContentType.ToLower());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.LogoutModel
|
||||
@@ -5,10 +5,13 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddControllers(); // Add controller support for API endpoints
|
||||
builder.Services.AddSingleton<DbExecutor>();
|
||||
builder.Services.AddSingleton<EncryptionService>(); // Singleton for encryption
|
||||
builder.Services.AddScoped<AuthService>();
|
||||
builder.Services.AddScoped<EmailService>();
|
||||
builder.Services.AddScoped<UserService>();
|
||||
builder.Services.AddScoped<MediaService>();
|
||||
|
||||
// Add session support
|
||||
builder.Services.AddDistributedMemoryCache();
|
||||
@@ -38,5 +41,6 @@ app.UseSession();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapRazorPages();
|
||||
app.MapControllers(); // Map API controllers
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
public class EncryptionService
|
||||
{
|
||||
private readonly byte[] _key;
|
||||
private readonly ILogger<EncryptionService> _logger;
|
||||
|
||||
public EncryptionService(IConfiguration configuration, ILogger<EncryptionService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var keyString = configuration["Encryption:Key"];
|
||||
|
||||
if (string.IsNullOrEmpty(keyString))
|
||||
{
|
||||
throw new InvalidOperationException("Encryption key not configured in appsettings.json");
|
||||
}
|
||||
|
||||
// Key must be 32 bytes for AES-256
|
||||
_key = Convert.FromBase64String(keyString);
|
||||
|
||||
if (_key.Length != 32)
|
||||
{
|
||||
throw new InvalidOperationException("Encryption key must be 32 bytes (256 bits) for AES-256");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts data using AES-256-CBC
|
||||
/// </summary>
|
||||
public async Task<byte[]> EncryptAsync(byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = _key;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.GenerateIV(); // Generate random IV for each encryption
|
||||
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
using var msEncrypt = new MemoryStream();
|
||||
|
||||
// Write IV to the beginning of the stream (needed for decryption)
|
||||
await msEncrypt.WriteAsync(aes.IV, 0, aes.IV.Length);
|
||||
|
||||
using (var csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
|
||||
{
|
||||
await csEncrypt.WriteAsync(data, 0, data.Length);
|
||||
await csEncrypt.FlushFinalBlockAsync();
|
||||
}
|
||||
|
||||
return msEncrypt.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to encrypt data");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts data using AES-256-CBC
|
||||
/// </summary>
|
||||
public async Task<byte[]> DecryptAsync(byte[] encryptedData)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = _key;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
|
||||
// Read IV from the beginning of the encrypted data
|
||||
var iv = new byte[16]; // AES block size is always 16 bytes
|
||||
Array.Copy(encryptedData, 0, iv, 0, iv.Length);
|
||||
aes.IV = iv;
|
||||
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
using var msDecrypt = new MemoryStream(encryptedData, iv.Length, encryptedData.Length - iv.Length);
|
||||
using var csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read);
|
||||
using var msPlain = new MemoryStream();
|
||||
|
||||
await csDecrypt.CopyToAsync(msPlain);
|
||||
return msPlain.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to decrypt data");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts a file and saves it to the destination path
|
||||
/// </summary>
|
||||
public async Task EncryptFileAsync(string sourcePath, string destinationPath)
|
||||
{
|
||||
var data = await File.ReadAllBytesAsync(sourcePath);
|
||||
var encrypted = await EncryptAsync(data);
|
||||
await File.WriteAllBytesAsync(destinationPath, encrypted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a file and returns the data
|
||||
/// </summary>
|
||||
public async Task<byte[]> DecryptFileAsync(string filePath)
|
||||
{
|
||||
var encryptedData = await File.ReadAllBytesAsync(filePath);
|
||||
return await DecryptAsync(encryptedData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new random encryption key (32 bytes for AES-256)
|
||||
/// </summary>
|
||||
public static string GenerateKey()
|
||||
{
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
var key = new byte[32];
|
||||
rng.GetBytes(key);
|
||||
return Convert.ToBase64String(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
public class MediaService
|
||||
{
|
||||
private readonly DbExecutor _db;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly EncryptionService _encryption;
|
||||
private readonly ILogger<MediaService> _logger;
|
||||
|
||||
public MediaService(DbExecutor db, IWebHostEnvironment environment, EncryptionService encryption, ILogger<MediaService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_environment = environment;
|
||||
_encryption = encryption;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<UserMedia?> SaveMediaAsync(long userId, IFormFile file, string? description = null)
|
||||
{
|
||||
string? tempFilePath = null;
|
||||
string? encryptedFilePath = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Generate unique filename
|
||||
var fileExtension = Path.GetExtension(file.FileName);
|
||||
var uniqueFileName = $"{Guid.NewGuid()}{fileExtension}.enc"; // .enc for encrypted
|
||||
|
||||
// Store outside wwwroot in App_Data folder
|
||||
var mediaFolder = Path.Combine(_environment.ContentRootPath, "App_Data", "media", userId.ToString());
|
||||
Directory.CreateDirectory(mediaFolder);
|
||||
|
||||
tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
encryptedFilePath = Path.Combine(mediaFolder, uniqueFileName);
|
||||
|
||||
// Save uploaded file temporarily
|
||||
using (var fileStream = new FileStream(tempFilePath, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(fileStream);
|
||||
}
|
||||
|
||||
// Get image dimensions before encryption
|
||||
int? width = null;
|
||||
int? height = null;
|
||||
|
||||
if (file.ContentType.StartsWith("image/"))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var image = await SixLabors.ImageSharp.Image.LoadAsync(tempFilePath);
|
||||
width = image.Width;
|
||||
height = image.Height;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read image dimensions for {FileName}", file.FileName);
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt and save
|
||||
await _encryption.EncryptFileAsync(tempFilePath, encryptedFilePath);
|
||||
|
||||
// Delete temp file
|
||||
File.Delete(tempFilePath);
|
||||
tempFilePath = null;
|
||||
|
||||
// Store relative path for database
|
||||
var relativeFilePath = Path.Combine("App_Data", "media", userId.ToString(), uniqueFileName);
|
||||
|
||||
// Insert into database
|
||||
var query = @"
|
||||
INSERT INTO app.user_media (user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, created_at, updated_at)
|
||||
VALUES (@userId, @fileName, @filePath, @fileSize, @mimeType, @width, @height, @description, @isEncrypted, @createdAt, @updatedAt)
|
||||
RETURNING id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, created_at, updated_at";
|
||||
|
||||
var media = await _db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new UserMedia
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
UserId = reader.GetInt64(1),
|
||||
FileName = reader.GetString(2),
|
||||
FilePath = reader.GetString(3),
|
||||
FileSize = reader.GetInt64(4),
|
||||
MimeType = reader.GetString(5),
|
||||
Width = reader.IsDBNull(6) ? null : reader.GetInt32(6),
|
||||
Height = reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
||||
Description = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
IsEncrypted = reader.GetBoolean(9),
|
||||
CreatedAt = reader.GetDateTime(10),
|
||||
UpdatedAt = reader.GetDateTime(11)
|
||||
};
|
||||
}, new
|
||||
{
|
||||
userId,
|
||||
fileName = file.FileName,
|
||||
filePath = relativeFilePath,
|
||||
fileSize = file.Length,
|
||||
mimeType = file.ContentType,
|
||||
width,
|
||||
height,
|
||||
description,
|
||||
isEncrypted = true,
|
||||
createdAt = DateTime.UtcNow,
|
||||
updatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
return media;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save media for user {UserId}", userId);
|
||||
|
||||
// Cleanup on error
|
||||
if (tempFilePath != null && File.Exists(tempFilePath))
|
||||
{
|
||||
try { File.Delete(tempFilePath); } catch { }
|
||||
}
|
||||
if (encryptedFilePath != null && File.Exists(encryptedFilePath))
|
||||
{
|
||||
try { File.Delete(encryptedFilePath); } catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<UserMedia>> GetUserMediaAsync(long userId, int offset = 0, int limit = 20)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, created_at, updated_at
|
||||
FROM app.user_media
|
||||
WHERE user_id = @userId
|
||||
ORDER BY created_at DESC
|
||||
OFFSET @offset LIMIT @limit";
|
||||
|
||||
var mediaList = await _db.ExecuteListReaderAsync(query, reader =>
|
||||
{
|
||||
return new UserMedia
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
UserId = reader.GetInt64(1),
|
||||
FileName = reader.GetString(2),
|
||||
FilePath = reader.GetString(3),
|
||||
FileSize = reader.GetInt64(4),
|
||||
MimeType = reader.GetString(5),
|
||||
Width = reader.IsDBNull(6) ? null : reader.GetInt32(6),
|
||||
Height = reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
||||
Description = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
IsEncrypted = reader.GetBoolean(9),
|
||||
CreatedAt = reader.GetDateTime(10),
|
||||
UpdatedAt = reader.GetDateTime(11)
|
||||
};
|
||||
}, new { userId, offset, limit });
|
||||
|
||||
return mediaList;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get media for user {UserId}", userId);
|
||||
return new List<UserMedia>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UserMedia?> GetMediaByIdAsync(long mediaId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, created_at, updated_at
|
||||
FROM app.user_media
|
||||
WHERE id = @mediaId AND user_id = @userId";
|
||||
|
||||
var media = await _db.ExecuteReaderAsync(query, reader =>
|
||||
{
|
||||
return new UserMedia
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
UserId = reader.GetInt64(1),
|
||||
FileName = reader.GetString(2),
|
||||
FilePath = reader.GetString(3),
|
||||
FileSize = reader.GetInt64(4),
|
||||
MimeType = reader.GetString(5),
|
||||
Width = reader.IsDBNull(6) ? null : reader.GetInt32(6),
|
||||
Height = reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
||||
Description = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
IsEncrypted = reader.GetBoolean(9),
|
||||
CreatedAt = reader.GetDateTime(10),
|
||||
UpdatedAt = reader.GetDateTime(11)
|
||||
};
|
||||
}, new { mediaId, userId });
|
||||
|
||||
return media;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get media {MediaId} for user {UserId}", mediaId, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetDecryptedMediaDataAsync(long mediaId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var media = await GetMediaByIdAsync(mediaId, userId);
|
||||
if (media == null)
|
||||
{
|
||||
_logger.LogWarning("Media {MediaId} not found or user {UserId} doesn't have access", mediaId, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.ContentRootPath, media.FilePath);
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogError("Media file not found at {FilePath}", fullPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (media.IsEncrypted)
|
||||
{
|
||||
return await _encryption.DecryptFileAsync(fullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
return await File.ReadAllBytesAsync(fullPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get decrypted media data for {MediaId}", mediaId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteMediaAsync(long mediaId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get media info first
|
||||
var query = "SELECT file_path FROM app.user_media WHERE id = @mediaId AND user_id = @userId";
|
||||
var filePath = await _db.ExecuteReaderAsync(query, reader => reader.GetString(0), new { mediaId, userId });
|
||||
|
||||
if (filePath == null) return false;
|
||||
|
||||
// Delete from database
|
||||
var deleteQuery = "DELETE FROM app.user_media WHERE id = @mediaId AND user_id = @userId";
|
||||
await _db.ExecuteAsync<object>(deleteQuery, new { mediaId, userId });
|
||||
|
||||
// Delete physical file
|
||||
var physicalPath = Path.Combine(_environment.ContentRootPath, filePath);
|
||||
if (File.Exists(physicalPath))
|
||||
{
|
||||
File.Delete(physicalPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete media {MediaId} for user {UserId}", mediaId, userId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,13 @@
|
||||
"FromEmail": "[email protected]",
|
||||
"FromName": "Media App",
|
||||
"EnableSsl": "true"
|
||||
},
|
||||
"FileUpload": {
|
||||
"MaxFileSizeMB": 10,
|
||||
"AllowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
||||
"UploadPath": "App_Data/media"
|
||||
},
|
||||
"Encryption": {
|
||||
"Key": "***REMOVED***"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* Gallery Grid Layout */
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.gallery-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gallery-item-description {
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
background: #f9f9f9;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.gallery-item-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.gallery-item-date {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
/* Upload Form Styles */
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group input[type="file"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Alert Messages */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
/* Image Modal */
|
||||
.image-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.modal-content img {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 36px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.gallery-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.gallery-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Lazy loading for gallery images using Intersection Observer
|
||||
let currentOffset = 20; // Initial load was 20 items
|
||||
let isLoading = false;
|
||||
let hasMore = true;
|
||||
|
||||
// Create intersection observer for lazy loading
|
||||
const loadingElement = document.getElementById('loading');
|
||||
const galleryElement = document.getElementById('gallery');
|
||||
|
||||
if (loadingElement && galleryElement) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !isLoading && hasMore) {
|
||||
loadMoreImages();
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '200px' // Start loading 200px before the element comes into view
|
||||
});
|
||||
|
||||
observer.observe(loadingElement);
|
||||
}
|
||||
|
||||
async function loadMoreImages() {
|
||||
if (isLoading || !hasMore) return;
|
||||
|
||||
isLoading = true;
|
||||
loadingElement.style.display = 'block';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/media/load?offset=${currentOffset}&limit=20`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load images');
|
||||
}
|
||||
|
||||
const mediaItems = await response.json();
|
||||
|
||||
if (mediaItems.length === 0) {
|
||||
hasMore = false;
|
||||
loadingElement.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new images to gallery
|
||||
mediaItems.forEach(media => {
|
||||
const galleryItem = createGalleryItem(media);
|
||||
galleryElement.appendChild(galleryItem);
|
||||
});
|
||||
|
||||
currentOffset += mediaItems.length;
|
||||
} catch (error) {
|
||||
console.error('Error loading images:', error);
|
||||
loadingElement.innerHTML = 'Failed to load more images.';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
if (hasMore) {
|
||||
loadingElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createGalleryItem(media) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'gallery-item';
|
||||
item.setAttribute('data-media-id', media.id);
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `/api/media/image/${media.id}`;
|
||||
img.alt = media.fileName;
|
||||
img.loading = 'lazy';
|
||||
item.appendChild(img);
|
||||
|
||||
if (media.description) {
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'gallery-item-description';
|
||||
desc.textContent = media.description;
|
||||
item.appendChild(desc);
|
||||
}
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'gallery-item-info';
|
||||
|
||||
const date = document.createElement('span');
|
||||
date.className = 'gallery-item-date';
|
||||
const createdDate = new Date(media.createdAt);
|
||||
date.textContent = createdDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
info.appendChild(date);
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = '/?handler=Delete';
|
||||
form.style.display = 'inline';
|
||||
|
||||
// Add anti-forgery token
|
||||
const tokenInput = document.querySelector('input[name="__RequestVerificationToken"]');
|
||||
if (tokenInput) {
|
||||
const tokenClone = tokenInput.cloneNode(true);
|
||||
form.appendChild(tokenClone);
|
||||
}
|
||||
|
||||
const hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = 'mediaId';
|
||||
hiddenInput.value = media.id;
|
||||
form.appendChild(hiddenInput);
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'submit';
|
||||
deleteBtn.className = 'btn-delete';
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.onclick = function(e) {
|
||||
return confirm('Are you sure you want to delete this image?');
|
||||
};
|
||||
form.appendChild(deleteBtn);
|
||||
|
||||
info.appendChild(form);
|
||||
item.appendChild(info);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// Optional: Add image preview on click
|
||||
galleryElement?.addEventListener('click', (e) => {
|
||||
if (e.target.tagName === 'IMG') {
|
||||
const modal = createImageModal(e.target.src);
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
});
|
||||
|
||||
function createImageModal(imageSrc) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'image-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content">
|
||||
<img src="${imageSrc}" alt="Full size image" />
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal-backdrop') ||
|
||||
e.target.classList.contains('modal-close') ||
|
||||
e.target.classList.contains('image-modal')) {
|
||||
modal.remove();
|
||||
}
|
||||
});
|
||||
|
||||
return modal;
|
||||
}
|
||||
Reference in New Issue
Block a user