Feature/breadboard #4
@@ -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">
|
||||||
|
|||||||
@@ -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 = ``;
|
||||||
|
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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user