Fix/restore blog api #2

Merged
jheaps merged 4 commits from fix/restore-blog-api into master 2026-08-26 17:23:58 -06:00
5 changed files with 152 additions and 1 deletions
Showing only changes of commit 941759f7c3 - Show all commits
+27
View File
@@ -107,6 +107,33 @@ public class BlogApi(BlogService blogService, DbExecutor dbExecutor) : Controlle
return Ok(new { success = true }); return Ok(new { success = true });
} }
[HttpPost("images")]
public async Task<IActionResult> 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 private static object MapToPublicDto(Models.BlogPost post) => new
{ {
post.Id, post.Id,
@@ -68,6 +68,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="postContent">Markdown Content</label> <label for="postContent">Markdown Content</label>
<div class="content-toolbar">
<button type="button" id="uploadImageBtn" class="btn btn-secondary btn-sm">Upload Image</button>
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp" style="display: none;" />
<span id="uploadStatus" class="upload-status"></span>
</div>
<textarea id="postContent" class="form-input form-textarea" placeholder="Write your post in markdown..."></textarea> <textarea id="postContent" class="form-input form-textarea" placeholder="Write your post in markdown..."></textarea>
</div> </div>
<div class="editor-actions"> <div class="editor-actions">
+55 -1
View File
@@ -4,8 +4,10 @@ using Media.JoshHeaps.Net.Models;
namespace Media.JoshHeaps.Net.Services; namespace Media.JoshHeaps.Net.Services;
public partial class BlogService(DbExecutor db, ILogger<BlogService> logger) public partial class BlogService(DbExecutor db, IWebHostEnvironment environment, ILogger<BlogService> logger)
{ {
private static readonly HashSet<string> AllowedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
private const long MaxImageSize = 10 * 1024 * 1024;
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions() .UseAdvancedExtensions()
.Build(); .Build();
@@ -166,6 +168,58 @@ public partial class BlogService(DbExecutor db, ILogger<BlogService> 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) private static BlogPost MapBlogPost(Npgsql.NpgsqlDataReader reader)
{ {
return new BlogPost return new BlogPost
@@ -229,6 +229,26 @@
border-color: var(--accent-primary); 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 { .form-textarea {
min-height: 400px; min-height: 400px;
resize: vertical; resize: vertical;
@@ -9,6 +9,51 @@
document.getElementById('newPostBtn').addEventListener('click', showNewPostEditor); document.getElementById('newPostBtn').addEventListener('click', showNewPostEditor);
document.getElementById('savePostBtn').addEventListener('click', savePost); document.getElementById('savePostBtn').addEventListener('click', savePost);
document.getElementById('cancelEditBtn').addEventListener('click', hideEditor); 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() { async function loadPosts() {