實戰:部落格 CMS 系統
專案概覽
💡 比喻:經營一本雜誌 CMS(內容管理系統)就像經營一本雜誌:
- 編輯器 = 記者寫稿的桌子
- 分類與標籤 = 雜誌的不同專欄
- 留言系統 = 讀者投書專區
- SEO = 讓更多人在書店找到你的雜誌
- 管理後台 = 總編輯的辦公室
專案結構
BlogCMS/
├── Controllers/
│ ├── BlogController.cs ← 前台文章展示
│ ├── AdminController.cs ← 後台管理
│ └── CommentController.cs ← 留言管理
├── Services/
│ ├── IPostService.cs ← 文章服務介面
│ ├── PostService.cs ← 文章服務實作
│ ├── IImageService.cs ← 圖片服務介面
│ └── ImageService.cs ← 圖片服務實作
├── Models/
│ ├── Post.cs ← 文章 Model
│ ├── Tag.cs ← 標籤 Model
│ └── Comment.cs ← 留言 Model
├── ViewModels/
│ ├── PostEditorVM.cs ← 編輯器 ViewModel
│ └── PostListVM.cs ← 文章列表 ViewModel
└── wwwroot/
└── uploads/ ← 圖片上傳目錄
Markdown 編輯器整合
// 文章 Model(支援 Markdown) // Post model with Markdown support
public class Post // 文章類別
{
public int Id { get; set; } // 主鍵
[Required(ErrorMessage = "標題必填")] // 驗證:標題必填
[StringLength(200)] // 限制長度
public string Title { get; set; } = ""; // 文章標題
[Required] // 必填
public string Slug { get; set; } = ""; // URL 友善的代稱
[Required(ErrorMessage = "內容必填")] // 驗證:內容必填
public string MarkdownContent { get; set; } = ""; // Markdown 原始內容
public string HtmlContent { get; set; } = ""; // 轉換後的 HTML
public string? Excerpt { get; set; } // 文章摘要
public string? FeaturedImage { get; set; } // 精選圖片
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // 建立時間
public DateTime? PublishedAt { get; set; } // 發佈時間
public bool IsPublished { get; set; } = false; // 是否已發佈
public int CategoryId { get; set; } // 分類 ID
public Category? Category { get; set; } // 導覽屬性:分類
public List<PostTag> PostTags { get; set; } = new(); // 多對多:文章標籤
public List<Comment> Comments { get; set; } = new(); // 一對多:留言
public int ViewCount { get; set; } = 0; // 瀏覽次數
}
// Slug 產生器 // Slug generator utility
public static class SlugGenerator // Slug 產生器靜態類別
{
public static string Generate(string title) // 從標題產生 Slug
{
var slug = title.ToLower().Trim(); // 轉小寫並去除頭尾空白
slug = Regex.Replace(slug, @"[^\w\u4e00-\u9fff\s-]", ""); // 移除特殊字元(保留中文)
slug = Regex.Replace(slug, @"[\s]+", "-"); // 空白替換為連字號
slug = Regex.Replace(slug, @"-+", "-"); // 多個連字號合併
slug = slug.Trim('-'); // 去除頭尾連字號
return slug; // 回傳 Slug
}
}
// Markdown 轉 HTML 服務(使用 Markdig) // Markdown to HTML service
public class MarkdownService // Markdown 轉換服務
{
private readonly MarkdownPipeline _pipeline; // Markdig 管線
public MarkdownService() // 建構函式
{
_pipeline = new MarkdownPipelineBuilder() // 建立管線建構器
.UseAdvancedExtensions() // 啟用進階擴充(表格、任務列表等)
.UseEmojiAndSmiley() // 支援 Emoji
.UseSyntaxHighlighting() // 程式碼語法高亮
.Build(); // 建置管線
}
public string ToHtml(string markdown) // Markdown 轉 HTML 方法
{
if (string.IsNullOrEmpty(markdown)) return ""; // 空內容回傳空字串
return Markdown.ToHtml(markdown, _pipeline); // 使用 Markdig 轉換
}
public string ToPlainText(string markdown, int maxLength = 200) // 產生摘要
{
var html = ToHtml(markdown); // 先轉 HTML
var text = Regex.Replace(html, "<[^>]+>", ""); // 移除 HTML 標籤
text = WebUtility.HtmlDecode(text); // 解碼 HTML 實體
return text.Length > maxLength // 如果超過長度限制
? text[..maxLength] + "..." // 截斷並加省略號
: text; // 否則回傳完整文字
}
}
前端整合 EasyMDE 編輯器
// PostEditorVM:傳給 View 的 ViewModel // Editor ViewModel
public class PostEditorVM // 文章編輯器 ViewModel
{
public int? Id { get; set; } // 文章 ID(新增時為 null)
[Required(ErrorMessage = "標題必填")] // 驗證
public string Title { get; set; } = ""; // 文章標題
[Required(ErrorMessage = "內容必填")] // 驗證
public string MarkdownContent { get; set; } = ""; // Markdown 內容
public int CategoryId { get; set; } // 分類 ID
public string TagIds { get; set; } = ""; // 標籤 ID(逗號分隔)
public IFormFile? FeaturedImage { get; set; } // 精選圖片上傳
public bool IsPublished { get; set; } // 是否立即發佈
public List<Category> AvailableCategories { get; set; } = new(); // 可選分類
public List<Tag> AvailableTags { get; set; } = new(); // 可選標籤
}
文章 CRUD + 分類標籤
多對多關係:文章與標籤
// 標籤 Model // Tag model
public class Tag // 標籤類別
{
public int Id { get; set; } // 主鍵
public string Name { get; set; } = ""; // 標籤名稱
public string Slug { get; set; } = ""; // URL 代稱
public List<PostTag> PostTags { get; set; } = new(); // 多對多關聯
}
// 多對多中間表 // Many-to-many join table
public class PostTag // 文章標籤關聯表
{
public int PostId { get; set; } // 文章 ID
public Post Post { get; set; } = null!; // 導覽屬性:文章
public int TagId { get; set; } // 標籤 ID
public Tag Tag { get; set; } = null!; // 導覽屬性:標籤
}
// DbContext 設定多對多關係 // Configure many-to-many in DbContext
protected override void OnModelCreating(ModelBuilder builder) // 設定模型
{
base.OnModelCreating(builder); // 呼叫基底方法
builder.Entity<PostTag>() // 設定 PostTag 實體
.HasKey(pt => new { pt.PostId, pt.TagId }); // 複合主鍵
builder.Entity<PostTag>() // 設定 Post 端的關係
.HasOne(pt => pt.Post) // 一篇文章
.WithMany(p => p.PostTags) // 有多個標籤關聯
.HasForeignKey(pt => pt.PostId); // 外鍵是 PostId
builder.Entity<PostTag>() // 設定 Tag 端的關係
.HasOne(pt => pt.Tag) // 一個標籤
.WithMany(t => t.PostTags) // 有多個文章關聯
.HasForeignKey(pt => pt.TagId); // 外鍵是 TagId
builder.Entity<Post>() // 設定 Post 的 Slug 索引
.HasIndex(p => p.Slug) // 對 Slug 建立索引
.IsUnique(); // 設定為唯一索引
}
文章 Service 完整實作
// 文章 Service // Post service
public class PostService : IPostService // 實作文章服務
{
private readonly AppDbContext _context; // 資料庫上下文
private readonly MarkdownService _markdown; // Markdown 服務
public PostService(AppDbContext context, MarkdownService markdown) // 建構函式
{
_context = context; // 儲存 DbContext
_markdown = markdown; // 儲存 Markdown 服務
}
public async Task<Post> CreatePostAsync(PostEditorVM vm) // 建立文章方法
{
var post = new Post // 建立文章物件
{
Title = vm.Title, // 設定標題
Slug = SlugGenerator.Generate(vm.Title), // 自動產生 Slug
MarkdownContent = vm.MarkdownContent, // 儲存 Markdown 原始碼
HtmlContent = _markdown.ToHtml(vm.MarkdownContent), // 轉換為 HTML
Excerpt = _markdown.ToPlainText(vm.MarkdownContent), // 產生摘要
CategoryId = vm.CategoryId, // 設定分類
IsPublished = vm.IsPublished, // 設定是否發佈
PublishedAt = vm.IsPublished ? DateTime.UtcNow : null, // 發佈時間
};
// 處理標籤 // Handle tags
if (!string.IsNullOrEmpty(vm.TagIds)) // 如果有選標籤
{
var tagIds = vm.TagIds.Split(',') // 分割標籤 ID 字串
.Select(int.Parse) // 轉為整數
.ToList(); // 轉為清單
post.PostTags = tagIds.Select(tid => new PostTag // 建立關聯
{
TagId = tid // 設定標籤 ID
}).ToList(); // 轉為清單
}
_context.Posts.Add(post); // 加入追蹤
await _context.SaveChangesAsync(); // 儲存到資料庫
return post; // 回傳建立的文章
}
public async Task<List<Post>> GetPublishedPostsAsync( // 取得已發佈文章
int page, int pageSize, string? category = null, string? tag = null) // 支援篩選
{
var query = _context.Posts // 從文章資料表開始
.Include(p => p.Category) // 載入分類
.Include(p => p.PostTags).ThenInclude(pt => pt.Tag) // 載入標籤
.Where(p => p.IsPublished) // 只取已發佈的
.AsQueryable(); // 轉為可查詢物件
if (!string.IsNullOrEmpty(category)) // 如果有指定分類
query = query.Where(p => p.Category!.Slug == category); // 篩選分類
if (!string.IsNullOrEmpty(tag)) // 如果有指定標籤
query = query.Where(p => p.PostTags.Any(pt => pt.Tag.Slug == tag)); // 篩選標籤
return await query // 執行查詢
.OrderByDescending(p => p.PublishedAt) // 依發佈時間降序
.Skip((page - 1) * pageSize) // 分頁跳過
.Take(pageSize) // 取指定筆數
.ToListAsync(); // 回傳結果
}
public async Task<Post?> GetPostBySlugAsync(string slug) // 依 Slug 取得文章
{
var post = await _context.Posts // 查詢文章
.Include(p => p.Category) // 載入分類
.Include(p => p.PostTags).ThenInclude(pt => pt.Tag) // 載入標籤
.Include(p => p.Comments.Where(c => c.IsApproved)) // 載入已審核的留言
.FirstOrDefaultAsync(p => p.Slug == slug && p.IsPublished); // 找到符合的文章
if (post != null) // 如果找到文章
{
post.ViewCount++; // 增加瀏覽次數
await _context.SaveChangesAsync(); // 儲存瀏覽次數
}
return post; // 回傳文章
}
}
留言系統
// 留言 Model // Comment model
public class Comment // 留言類別
{
public int Id { get; set; } // 主鍵
[Required(ErrorMessage = "請輸入留言內容")] // 驗證
[StringLength(1000, ErrorMessage = "留言最多 1000 字")] // 長度限制
public string Content { get; set; } = ""; // 留言內容
[Required(ErrorMessage = "請輸入暱稱")] // 驗證
[StringLength(50)] // 長度限制
public string AuthorName { get; set; } = ""; // 留言者暱稱
[EmailAddress] // Email 驗證
public string? AuthorEmail { get; set; } // 留言者 Email(選填)
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // 留言時間
public bool IsApproved { get; set; } = false; // 是否已審核
public int PostId { get; set; } // 所屬文章 ID
public Post? Post { get; set; } // 導覽屬性:文章
public int? ParentId { get; set; } // 父留言 ID(回覆用)
public Comment? Parent { get; set; } // 導覽屬性:父留言
public List<Comment> Replies { get; set; } = new(); // 子留言清單
}
// 留言 Service // Comment service
public class CommentService // 留言服務類別
{
private readonly AppDbContext _context; // 資料庫上下文
public CommentService(AppDbContext context) // 建構函式
{
_context = context; // 儲存 DbContext
}
public async Task<Comment> AddCommentAsync(Comment comment) // 新增留言
{
// 簡單的垃圾留言過濾 // Simple spam filter
var spamWords = new[] { "casino", "viagra", "loan" }; // 垃圾關鍵字清單
var contentLower = comment.Content.ToLower(); // 轉小寫比對
if (spamWords.Any(w => contentLower.Contains(w))) // 檢查是否含垃圾字
{
throw new InvalidOperationException("留言內容包含不允許的字詞"); // 拒絕垃圾留言
}
comment.IsApproved = false; // 預設未審核
comment.CreatedAt = DateTime.UtcNow; // 設定留言時間
_context.Comments.Add(comment); // 加入追蹤
await _context.SaveChangesAsync(); // 儲存到資料庫
return comment; // 回傳留言
}
public async Task<List<Comment>> GetCommentsForPostAsync(int postId) // 取得文章留言
{
return await _context.Comments // 查詢留言
.Where(c => c.PostId == postId && c.IsApproved) // 篩選已審核的
.Where(c => c.ParentId == null) // 只取頂層留言
.Include(c => c.Replies.Where(r => r.IsApproved)) // 載入已審核的回覆
.OrderByDescending(c => c.CreatedAt) // 依時間降序
.ToListAsync(); // 回傳結果
}
public async Task ApproveCommentAsync(int commentId) // 審核留言
{
var comment = await _context.Comments.FindAsync(commentId); // 找到留言
if (comment != null) // 如果找到
{
comment.IsApproved = true; // 設定為已審核
await _context.SaveChangesAsync(); // 儲存
}
}
}
圖片上傳與管理
// 圖片上傳 Service // Image upload service
public class ImageService : IImageService // 實作圖片服務
{
private readonly IWebHostEnvironment _env; // 網站環境
private readonly long _maxFileSize = 5 * 1024 * 1024; // 最大 5MB
private readonly string[] _allowedExts = { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; // 允許的副檔名
public ImageService(IWebHostEnvironment env) // 建構函式
{
_env = env; // 儲存環境參考
}
public async Task<string> UploadImageAsync(IFormFile file) // 上傳圖片方法
{
// 驗證檔案大小 // Validate file size
if (file.Length == 0) throw new ArgumentException("檔案是空的"); // 檔案不可為空
if (file.Length > _maxFileSize) throw new ArgumentException("檔案大小不可超過 5MB"); // 大小限制
// 驗證副檔名 // Validate extension
var ext = Path.GetExtension(file.FileName).ToLower(); // 取得副檔名
if (!_allowedExts.Contains(ext)) // 檢查副檔名
throw new ArgumentException($"不支援的檔案格式:{ext}"); // 格式不允許
// 產生唯一檔名 // Generate unique filename
var fileName = $"{Guid.NewGuid()}{ext}"; // 用 GUID 產生唯一檔名
var uploadDir = Path.Combine(_env.WebRootPath, "uploads"); // 上傳目錄路徑
if (!Directory.Exists(uploadDir)) // 如果目錄不存在
Directory.CreateDirectory(uploadDir); // 建立上傳目錄
var filePath = Path.Combine(uploadDir, fileName); // 完整檔案路徑
// 儲存檔案 // Save file
using var stream = new FileStream(filePath, FileMode.Create); // 建立檔案串流
await file.CopyToAsync(stream); // 複製上傳檔案到磁碟
return $"/uploads/{fileName}"; // 回傳相對路徑
}
public void DeleteImage(string imageUrl) // 刪除圖片方法
{
if (string.IsNullOrEmpty(imageUrl)) return; // 空路徑直接返回
var filePath = Path.Combine(_env.WebRootPath, imageUrl.TrimStart('/')); // 組合完整路徑
if (File.Exists(filePath)) File.Delete(filePath); // 如果存在就刪除
}
}
// Markdown 編輯器的圖片上傳 API // Image upload API for editor
[HttpPost("/api/upload/image")] // 圖片上傳端點
[Authorize] // 需要登入
public async Task<IActionResult> UploadImage(IFormFile file) // 上傳 Action
{
try // 嘗試上傳
{
var url = await _imageService.UploadImageAsync(file); // 呼叫上傳服務
return Ok(new { url }); // 回傳圖片 URL(給 EasyMDE 用)
}
catch (ArgumentException ex) // 捕捉驗證錯誤
{
return BadRequest(new { error = ex.Message }); // 回傳 400 錯誤
}
}
SEO 優化 (meta tags, sitemap, robots.txt)
// SEO 元資料 ViewModel // SEO metadata ViewModel
public class SeoMetadata // SEO 元資料類別
{
public string Title { get; set; } = ""; // 頁面標題
public string Description { get; set; } = ""; // 描述
public string? OgImage { get; set; } // Open Graph 圖片
public string? CanonicalUrl { get; set; } // 標準網址
public string? Author { get; set; } // 作者
}
// Sitemap 產生器 // Sitemap generator
[Route("sitemap.xml")] // 設定路由
public async Task<IActionResult> Sitemap() // Sitemap Action
{
var posts = await _context.Posts // 查詢所有已發佈文章
.Where(p => p.IsPublished) // 只取已發佈的
.Select(p => new { p.Slug, p.PublishedAt }) // 只取需要的欄位
.ToListAsync(); // 執行查詢
var sb = new StringBuilder(); // 建立字串建構器
sb.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); // XML 宣告
sb.AppendLine("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"); // Sitemap 根元素
// 首頁 // Homepage
sb.AppendLine(" <url>"); // URL 元素開始
sb.AppendLine($" <loc>{Request.Scheme}://{Request.Host}/</loc>"); // 首頁網址
sb.AppendLine(" <changefreq>daily</changefreq>"); // 更新頻率:每天
sb.AppendLine(" <priority>1.0</priority>"); // 優先度:最高
sb.AppendLine(" </url>"); // URL 元素結束
foreach (var post in posts) // 逐一加入文章
{
sb.AppendLine(" <url>"); // URL 元素開始
sb.AppendLine($" <loc>{Request.Scheme}://{Request.Host}/blog/{post.Slug}</loc>"); // 文章網址
sb.AppendLine($" <lastmod>{post.PublishedAt:yyyy-MM-dd}</lastmod>"); // 最後修改日
sb.AppendLine(" <changefreq>monthly</changefreq>"); // 更新頻率:每月
sb.AppendLine(" <priority>0.8</priority>"); // 優先度
sb.AppendLine(" </url>"); // URL 元素結束
}
sb.AppendLine("</urlset>"); // 根元素結束
return Content(sb.ToString(), "application/xml"); // 回傳 XML
}
// robots.txt // robots.txt endpoint
[Route("robots.txt")] // 設定路由
public IActionResult Robots() // robots.txt Action
{
var content = $"User-agent: *\n" + // 所有搜尋引擎
$"Allow: /\n" + // 允許存取所有頁面
$"Disallow: /admin/\n" + // 禁止存取管理後台
$"Disallow: /api/\n" + // 禁止存取 API
$"Sitemap: {Request.Scheme}://{Request.Host}/sitemap.xml"; // Sitemap 位置
return Content(content, "text/plain"); // 回傳純文字
}
RSS Feed 產生
// RSS Feed 產生器 // RSS feed generator
[Route("feed.xml")] // 設定路由
public async Task<IActionResult> RssFeed() // RSS Feed Action
{
var posts = await _context.Posts // 查詢文章
.Where(p => p.IsPublished) // 已發佈的
.OrderByDescending(p => p.PublishedAt) // 依發佈時間降序
.Take(20) // 取最新 20 篇
.ToListAsync(); // 執行查詢
var sb = new StringBuilder(); // 建立字串建構器
sb.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); // XML 宣告
sb.AppendLine("<rss version=\"2.0\">"); // RSS 根元素
sb.AppendLine(" <channel>"); // 頻道開始
sb.AppendLine(" <title>我的部落格</title>"); // 頻道標題
sb.AppendLine($" <link>{Request.Scheme}://{Request.Host}</link>"); // 網站網址
sb.AppendLine(" <description>技術部落格</description>"); // 頻道描述
sb.AppendLine($" <language>zh-TW</language>"); // 語言
foreach (var post in posts) // 逐一加入文章
{
sb.AppendLine(" <item>"); // 項目開始
sb.AppendLine($" <title>{WebUtility.HtmlEncode(post.Title)}</title>"); // 文章標題
sb.AppendLine($" <link>{Request.Scheme}://{Request.Host}/blog/{post.Slug}</link>"); // 文章連結
sb.AppendLine($" <description>{WebUtility.HtmlEncode(post.Excerpt ?? "")}</description>"); // 摘要
sb.AppendLine($" <pubDate>{post.PublishedAt:R}</pubDate>"); // 發佈日期(RFC 822)
sb.AppendLine($" <guid>{Request.Scheme}://{Request.Host}/blog/{post.Slug}</guid>"); // 唯一識別碼
sb.AppendLine(" </item>"); // 項目結束
}
sb.AppendLine(" </channel>"); // 頻道結束
sb.AppendLine("</rss>"); // RSS 結束
return Content(sb.ToString(), "application/rss+xml"); // 回傳 RSS XML
}
管理員後台
// 管理後台 Controller // Admin controller
[Authorize(Roles = "Admin")] // 整個 Controller 限定 Admin
[Route("admin")] // 路由前綴
public class AdminController : Controller // 管理後台 Controller
{
private readonly IPostService _postService; // 文章服務
private readonly CommentService _commentService; // 留言服務
public AdminController(IPostService postService, CommentService commentService) // 建構函式
{
_postService = postService; // 儲存文章服務
_commentService = commentService; // 儲存留言服務
}
// 後台首頁:儀表板 // Dashboard
[HttpGet("")] // admin/
public async Task<IActionResult> Dashboard() // 儀表板 Action
{
var stats = new DashboardVM // 建立儀表板 ViewModel
{
TotalPosts = await _postService.GetTotalPostCountAsync(), // 文章總數
PublishedPosts = await _postService.GetPublishedCountAsync(), // 已發佈數
DraftPosts = await _postService.GetDraftCountAsync(), // 草稿數
PendingComments = await _commentService.GetPendingCountAsync(), // 待審核留言
TotalViews = await _postService.GetTotalViewsAsync(), // 總瀏覽次數
};
return View(stats); // 回傳儀表板
}
// 文章管理列表 // Post management list
[HttpGet("posts")] // admin/posts
public async Task<IActionResult> Posts(int page = 1) // 文章管理 Action
{
var posts = await _postService.GetAllPostsAsync(page, 20); // 取得所有文章(含草稿)
return View(posts); // 回傳文章列表
}
// 待審核留言 // Pending comments
[HttpGet("comments/pending")] // admin/comments/pending
public async Task<IActionResult> PendingComments() // 待審核留言 Action
{
var comments = await _commentService.GetPendingCommentsAsync(); // 取得待審核留言
return View(comments); // 回傳留言列表
}
// 審核留言 // Approve comment
[HttpPost("comments/{id}/approve")] // admin/comments/5/approve
public async Task<IActionResult> ApproveComment(int id) // 審核 Action
{
await _commentService.ApproveCommentAsync(id); // 審核通過
TempData["Success"] = "留言已審核通過"; // 成功訊息
return RedirectToAction(nameof(PendingComments)); // 導回待審核頁
}
}
// 儀表板 ViewModel // Dashboard ViewModel
public class DashboardVM // 儀表板資料
{
public int TotalPosts { get; set; } // 文章總數
public int PublishedPosts { get; set; } // 已發佈數
public int DraftPosts { get; set; } // 草稿數
public int PendingComments { get; set; } // 待審核留言數
public int TotalViews { get; set; } // 總瀏覽次數
}
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:Slug 沒有做唯一性檢查
// ❌ 錯誤:直接用標題產生 Slug,沒檢查重複 // Mistake: no uniqueness check
var slug = SlugGenerator.Generate(post.Title); // 如果兩篇文章同名就爆了
post.Slug = slug; // 直接存入
// ✅ 正確:檢查重複並加上後綴 // Correct: check and append suffix
public async Task<string> GenerateUniqueSlugAsync(string title) // 產生唯一 Slug
{
var slug = SlugGenerator.Generate(title); // 先產生基本 Slug
var baseSlug = slug; // 保存原始 Slug
var counter = 1; // 計數器
while (await _context.Posts.AnyAsync(p => p.Slug == slug)) // 如果已存在
{
slug = $"{baseSlug}-{counter}"; // 加上數字後綴
counter++; // 計數器加一
}
return slug; // 回傳唯一的 Slug
}
❌ 錯誤 2:圖片上傳沒有驗證內容
// ❌ 錯誤:只檢查副檔名 // Mistake: only checking extension
if (file.FileName.EndsWith(".jpg")) // 只看副檔名
{
// 有人可以把 .exe 改名為 .jpg 上傳! // Someone could rename .exe to .jpg!
}
// ✅ 正確:也要檢查檔案內容 // Correct: also check file content
public bool IsValidImage(IFormFile file) // 驗證是否為真正的圖片
{
var ext = Path.GetExtension(file.FileName).ToLower(); // 檢查副檔名
if (!_allowedExts.Contains(ext)) return false; // 副檔名不對就拒絕
using var reader = new BinaryReader(file.OpenReadStream()); // 讀取檔案內容
var headerBytes = reader.ReadBytes(4); // 讀取前 4 個位元組
var header = BitConverter.ToString(headerBytes); // 轉為 16 進位字串
return header.StartsWith("FF-D8") // JPEG 檔頭
|| header.StartsWith("89-50-4E-47") // PNG 檔頭
|| header.StartsWith("47-49-46"); // GIF 檔頭
}
❌ 錯誤 3:留言沒有防止 XSS 攻擊
// ❌ 錯誤:直接顯示留言內容 // Mistake: rendering raw comment content
@Html.Raw(comment.Content) // 如果留言包含 <script> 標籤就完蛋了!
// ✅ 正確:一定要 HTML 編碼 // Correct: always HTML encode
@comment.Content // Razor 預設會自動編碼
// 或者手動編碼 // Or manually encode
@Html.Encode(comment.Content) // 手動 HTML 編碼
📋 本章重點
| 功能 | 關鍵技術 | 注意事項 |
|---|---|---|
| Markdown 編輯 | Markdig + EasyMDE | 儲存原始碼和 HTML |
| 多對多標籤 | PostTag 中間表 | 設定複合主鍵 |
| 圖片上傳 | IFormFile + GUID 檔名 | 驗證副檔名和內容 |
| SEO | Sitemap + meta tags | Slug 要唯一 |
| 留言 | 審核機制 + 防 XSS | 永遠不用 Html.Raw |
🎯 下一步:來建構 RESTful API 微服務!