From 967f563e28fa9b7ed5dcbcf9d04f08eb51443cca Mon Sep 17 00:00:00 2001 From: jheaps Date: Wed, 8 Oct 2025 14:30:22 -0600 Subject: [PATCH] Add logout and image upload functionality --- .claude/settings.local.json | 12 + .gitignore | 5 +- Media.JoshHeaps.Net/Api/MediaApi.cs | 62 ++++ .../Database/003_user_media.sql | 21 ++ Media.JoshHeaps.Net/DbExecutor.cs | 25 ++ .../Media.JoshHeaps.Net.csproj | 1 + Media.JoshHeaps.Net/Models/UserMedia.cs | 17 ++ Media.JoshHeaps.Net/Pages/Index.cshtml | 56 +++- Media.JoshHeaps.Net/Pages/Index.cshtml.cs | 69 ++++- Media.JoshHeaps.Net/Pages/Logout.cshtml | 2 + Media.JoshHeaps.Net/Program.cs | 4 + .../Services/EncryptionService.cs | 124 ++++++++ Media.JoshHeaps.Net/Services/MediaService.cs | 270 ++++++++++++++++++ Media.JoshHeaps.Net/appsettings.json | 8 + Media.JoshHeaps.Net/wwwroot/css/gallery.css | 191 +++++++++++++ Media.JoshHeaps.Net/wwwroot/js/gallery.js | 151 ++++++++++ 16 files changed, 1013 insertions(+), 5 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 Media.JoshHeaps.Net/Api/MediaApi.cs create mode 100644 Media.JoshHeaps.Net/Database/003_user_media.sql create mode 100644 Media.JoshHeaps.Net/Models/UserMedia.cs create mode 100644 Media.JoshHeaps.Net/Pages/Logout.cshtml create mode 100644 Media.JoshHeaps.Net/Services/EncryptionService.cs create mode 100644 Media.JoshHeaps.Net/Services/MediaService.cs create mode 100644 Media.JoshHeaps.Net/wwwroot/css/gallery.css create mode 100644 Media.JoshHeaps.Net/wwwroot/js/gallery.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..77e7aa4 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet build)", + "Bash(dotnet run:*)", + "Bash(powershell:*)", + "Bash(dotnet script:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.gitignore b/.gitignore index 9491a2f..4ed7d09 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,7 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +# User uploaded media files (encrypted) +**/App_Data/ \ No newline at end of file diff --git a/Media.JoshHeaps.Net/Api/MediaApi.cs b/Media.JoshHeaps.Net/Api/MediaApi.cs new file mode 100644 index 0000000..ac9ab9c --- /dev/null +++ b/Media.JoshHeaps.Net/Api/MediaApi.cs @@ -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 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 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); + } +} diff --git a/Media.JoshHeaps.Net/Database/003_user_media.sql b/Media.JoshHeaps.Net/Database/003_user_media.sql new file mode 100644 index 0000000..f79e1d3 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/003_user_media.sql @@ -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); diff --git a/Media.JoshHeaps.Net/DbExecutor.cs b/Media.JoshHeaps.Net/DbExecutor.cs index aa8b906..b9b38ca 100644 --- a/Media.JoshHeaps.Net/DbExecutor.cs +++ b/Media.JoshHeaps.Net/DbExecutor.cs @@ -78,4 +78,29 @@ public class DbExecutor(IConfiguration config) return default; } + + // Returns a list using custom mapping + public async Task> ExecuteListReaderAsync(string query, Func 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(); + + while (await reader.ReadAsync()) + { + results.Add(mapper(reader)); + } + + return results; + } } diff --git a/Media.JoshHeaps.Net/Media.JoshHeaps.Net.csproj b/Media.JoshHeaps.Net/Media.JoshHeaps.Net.csproj index 0261f1d..d11ba80 100644 --- a/Media.JoshHeaps.Net/Media.JoshHeaps.Net.csproj +++ b/Media.JoshHeaps.Net/Media.JoshHeaps.Net.csproj @@ -11,6 +11,7 @@ + diff --git a/Media.JoshHeaps.Net/Models/UserMedia.cs b/Media.JoshHeaps.Net/Models/UserMedia.cs new file mode 100644 index 0000000..cb33ebd --- /dev/null +++ b/Media.JoshHeaps.Net/Models/UserMedia.cs @@ -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; } +} diff --git a/Media.JoshHeaps.Net/Pages/Index.cshtml b/Media.JoshHeaps.Net/Pages/Index.cshtml index 158c33e..76a6ee1 100644 --- a/Media.JoshHeaps.Net/Pages/Index.cshtml +++ b/Media.JoshHeaps.Net/Pages/Index.cshtml @@ -7,6 +7,7 @@ @section Styles { + }
@@ -102,4 +103,57 @@ Logout
- \ No newline at end of file + +
+

Upload Image

+ @if (!string.IsNullOrEmpty(Model.UploadMessage)) + { +
+ @Model.UploadMessage +
+ } +
+ @Html.AntiForgeryToken() +
+ + +
+
+ + +
+ +
+
+ +
+

My Images (@Model.MediaItems.Count)

+ + +
+ + +@section Scripts { + +} \ No newline at end of file diff --git a/Media.JoshHeaps.Net/Pages/Index.cshtml.cs b/Media.JoshHeaps.Net/Pages/Index.cshtml.cs index 8b978d2..ea74fb8 100644 --- a/Media.JoshHeaps.Net/Pages/Index.cshtml.cs +++ b/Media.JoshHeaps.Net/Pages/Index.cshtml.cs @@ -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 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 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 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 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()); + } } } diff --git a/Media.JoshHeaps.Net/Pages/Logout.cshtml b/Media.JoshHeaps.Net/Pages/Logout.cshtml new file mode 100644 index 0000000..cdedac4 --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/Logout.cshtml @@ -0,0 +1,2 @@ +@page +@model Media.JoshHeaps.Net.Pages.LogoutModel diff --git a/Media.JoshHeaps.Net/Program.cs b/Media.JoshHeaps.Net/Program.cs index ea5da4b..45621d7 100644 --- a/Media.JoshHeaps.Net/Program.cs +++ b/Media.JoshHeaps.Net/Program.cs @@ -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(); +builder.Services.AddSingleton(); // Singleton for encryption builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Add session support builder.Services.AddDistributedMemoryCache(); @@ -38,5 +41,6 @@ app.UseSession(); app.UseAuthorization(); app.MapRazorPages(); +app.MapControllers(); // Map API controllers app.Run(); diff --git a/Media.JoshHeaps.Net/Services/EncryptionService.cs b/Media.JoshHeaps.Net/Services/EncryptionService.cs new file mode 100644 index 0000000..ddbc53e --- /dev/null +++ b/Media.JoshHeaps.Net/Services/EncryptionService.cs @@ -0,0 +1,124 @@ +using System.Security.Cryptography; + +namespace Media.JoshHeaps.Net.Services; + +public class EncryptionService +{ + private readonly byte[] _key; + private readonly ILogger _logger; + + public EncryptionService(IConfiguration configuration, ILogger 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"); + } + } + + /// + /// Encrypts data using AES-256-CBC + /// + public async Task 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; + } + } + + /// + /// Decrypts data using AES-256-CBC + /// + public async Task 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; + } + } + + /// + /// Encrypts a file and saves it to the destination path + /// + public async Task EncryptFileAsync(string sourcePath, string destinationPath) + { + var data = await File.ReadAllBytesAsync(sourcePath); + var encrypted = await EncryptAsync(data); + await File.WriteAllBytesAsync(destinationPath, encrypted); + } + + /// + /// Decrypts a file and returns the data + /// + public async Task DecryptFileAsync(string filePath) + { + var encryptedData = await File.ReadAllBytesAsync(filePath); + return await DecryptAsync(encryptedData); + } + + /// + /// Generates a new random encryption key (32 bytes for AES-256) + /// + public static string GenerateKey() + { + using var rng = RandomNumberGenerator.Create(); + var key = new byte[32]; + rng.GetBytes(key); + return Convert.ToBase64String(key); + } +} diff --git a/Media.JoshHeaps.Net/Services/MediaService.cs b/Media.JoshHeaps.Net/Services/MediaService.cs new file mode 100644 index 0000000..4faf48b --- /dev/null +++ b/Media.JoshHeaps.Net/Services/MediaService.cs @@ -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 _logger; + + public MediaService(DbExecutor db, IWebHostEnvironment environment, EncryptionService encryption, ILogger logger) + { + _db = db; + _environment = environment; + _encryption = encryption; + _logger = logger; + } + + public async Task 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> 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(); + } + } + + public async Task 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 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 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(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; + } + } +} diff --git a/Media.JoshHeaps.Net/appsettings.json b/Media.JoshHeaps.Net/appsettings.json index 9e3974d..4320cf6 100644 --- a/Media.JoshHeaps.Net/appsettings.json +++ b/Media.JoshHeaps.Net/appsettings.json @@ -15,5 +15,13 @@ "FromEmail": "noreply@example.com", "FromName": "Media App", "EnableSsl": "true" + }, + "FileUpload": { + "MaxFileSizeMB": 10, + "AllowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"], + "UploadPath": "App_Data/media" + }, + "Encryption": { + "Key": "***REMOVED***" } } diff --git a/Media.JoshHeaps.Net/wwwroot/css/gallery.css b/Media.JoshHeaps.Net/wwwroot/css/gallery.css new file mode 100644 index 0000000..0a0c53d --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/css/gallery.css @@ -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; + } +} diff --git a/Media.JoshHeaps.Net/wwwroot/js/gallery.js b/Media.JoshHeaps.Net/wwwroot/js/gallery.js new file mode 100644 index 0000000..fddbe4c --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/gallery.js @@ -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 = ` + + + `; + + 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; +}