Fix/restore blog api #2
@@ -107,6 +107,33 @@ public class BlogApi(BlogService blogService, DbExecutor dbExecutor) : Controlle
|
||||
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
|
||||
{
|
||||
post.Id,
|
||||
|
||||
@@ -68,6 +68,11 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
|
||||
@@ -4,8 +4,10 @@ using Media.JoshHeaps.Net.Models;
|
||||
|
||||
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()
|
||||
.UseAdvancedExtensions()
|
||||
.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)
|
||||
{
|
||||
return new BlogPost
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = ``;
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user