diff --git a/Media.JoshHeaps.Net/Api/BlogApi.cs b/Media.JoshHeaps.Net/Api/BlogApi.cs index 46b5f23..f40ae8a 100644 --- a/Media.JoshHeaps.Net/Api/BlogApi.cs +++ b/Media.JoshHeaps.Net/Api/BlogApi.cs @@ -107,6 +107,33 @@ public class BlogApi(BlogService blogService, DbExecutor dbExecutor) : Controlle return Ok(new { success = true }); } + [HttpPost("images")] + public async Task UploadImage(IFormFile file) + { + var userId = GetUserIdFromAuth(); + if (userId is null) return Unauthorized(); + if (!await IsAdmin(userId.Value)) return Forbid(); + + if (file is null || file.Length == 0) + return BadRequest(new { error = "No file provided" }); + + var (url, error) = await blogService.SaveImageAsync(file); + if (url is null) + return BadRequest(new { error }); + + return Ok(new { url }); + } + + [HttpGet("images/{fileName}")] + public IActionResult GetImage(string fileName) + { + var (filePath, mimeType) = blogService.GetImagePath(fileName); + if (filePath is null) + return NotFound(); + + return PhysicalFile(filePath, mimeType!); + } + private static object MapToPublicDto(Models.BlogPost post) => new { post.Id, diff --git a/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml b/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml index c1495ee..ff97963 100644 --- a/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml +++ b/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml @@ -68,6 +68,11 @@
+
+ + + +
diff --git a/Media.JoshHeaps.Net/Services/BlogService.cs b/Media.JoshHeaps.Net/Services/BlogService.cs index 260f481..871ad8b 100644 --- a/Media.JoshHeaps.Net/Services/BlogService.cs +++ b/Media.JoshHeaps.Net/Services/BlogService.cs @@ -4,8 +4,10 @@ using Media.JoshHeaps.Net.Models; namespace Media.JoshHeaps.Net.Services; -public partial class BlogService(DbExecutor db, ILogger logger) +public partial class BlogService(DbExecutor db, IWebHostEnvironment environment, ILogger logger) { + private static readonly HashSet AllowedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; + private const long MaxImageSize = 10 * 1024 * 1024; private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() .UseAdvancedExtensions() .Build(); @@ -166,6 +168,58 @@ public partial class BlogService(DbExecutor db, ILogger logger) } } + public async Task<(string? url, string? error)> SaveImageAsync(IFormFile file) + { + if (!AllowedMimeTypes.Contains(file.ContentType)) + return (null, "Only JPEG, PNG, GIF, and WEBP images are allowed"); + + if (file.Length > MaxImageSize) + return (null, "Image must be under 10MB"); + + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (string.IsNullOrEmpty(ext)) ext = ".jpg"; + + var fileName = $"{Guid.NewGuid()}{ext}"; + var folder = Path.Combine(environment.ContentRootPath, "App_Data", "blog"); + Directory.CreateDirectory(folder); + + var filePath = Path.Combine(folder, fileName); + try + { + await using var stream = new FileStream(filePath, FileMode.Create); + await file.CopyToAsync(stream); + return ($"/api/blog/images/{fileName}", null); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save blog image"); + if (File.Exists(filePath)) File.Delete(filePath); + return (null, "Failed to save image"); + } + } + + public (string? filePath, string? mimeType) GetImagePath(string fileName) + { + if (fileName.Contains("..") || fileName.Contains('/') || fileName.Contains('\\')) + return (null, null); + + var filePath = Path.Combine(environment.ContentRootPath, "App_Data", "blog", fileName); + if (!File.Exists(filePath)) + return (null, null); + + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + var mimeType = ext switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + _ => "application/octet-stream" + }; + + return (filePath, mimeType); + } + private static BlogPost MapBlogPost(Npgsql.NpgsqlDataReader reader) { return new BlogPost diff --git a/Media.JoshHeaps.Net/wwwroot/css/blog-admin.css b/Media.JoshHeaps.Net/wwwroot/css/blog-admin.css index ba0bf99..40bf6c1 100644 --- a/Media.JoshHeaps.Net/wwwroot/css/blog-admin.css +++ b/Media.JoshHeaps.Net/wwwroot/css/blog-admin.css @@ -229,6 +229,26 @@ border-color: var(--accent-primary); } +.content-toolbar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.upload-status { + font-size: 13px; + color: var(--text-secondary); +} + +.upload-error { + color: var(--danger); +} + +.upload-success { + color: var(--success, #22c55e); +} + .form-textarea { min-height: 400px; resize: vertical; diff --git a/Media.JoshHeaps.Net/wwwroot/js/blog-admin.js b/Media.JoshHeaps.Net/wwwroot/js/blog-admin.js index 2ed21a9..8052fae 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/blog-admin.js +++ b/Media.JoshHeaps.Net/wwwroot/js/blog-admin.js @@ -9,6 +9,51 @@ document.getElementById('newPostBtn').addEventListener('click', showNewPostEditor); document.getElementById('savePostBtn').addEventListener('click', savePost); document.getElementById('cancelEditBtn').addEventListener('click', hideEditor); + + const imageInput = document.getElementById('imageFileInput'); + document.getElementById('uploadImageBtn').addEventListener('click', () => imageInput.click()); + imageInput.addEventListener('change', uploadImage); + } + + async function uploadImage() { + const input = document.getElementById('imageFileInput'); + const status = document.getElementById('uploadStatus'); + if (!input.files.length) return; + + const file = input.files[0]; + const formData = new FormData(); + formData.append('file', file); + + status.textContent = 'Uploading...'; + status.className = 'upload-status'; + + try { + const res = await fetch('/api/blog/images', { method: 'POST', body: formData }); + if (!res.ok) { + const err = await res.json(); + status.textContent = err.error || 'Upload failed'; + status.className = 'upload-status upload-error'; + return; + } + const { url } = await res.json(); + const textarea = document.getElementById('postContent'); + const markdown = `![${file.name}](${url})`; + const pos = textarea.selectionStart; + const before = textarea.value.substring(0, pos); + const after = textarea.value.substring(pos); + const needsNewline = before.length > 0 && !before.endsWith('\n') ? '\n' : ''; + textarea.value = before + needsNewline + markdown + '\n' + after; + textarea.focus(); + textarea.selectionStart = textarea.selectionEnd = pos + needsNewline.length + markdown.length + 1; + status.textContent = 'Uploaded!'; + status.className = 'upload-status upload-success'; + setTimeout(() => { status.textContent = ''; }, 2000); + } catch { + status.textContent = 'Upload failed'; + status.className = 'upload-status upload-error'; + } finally { + input.value = ''; + } } async function loadPosts() {