first commit, mate!
CI/CD Pipeline / Test & Lint (push) Waiting to run
CI/CD Pipeline / Build Docker Image (push) Blocked by required conditions
CI/CD Pipeline / Security Scan (push) Waiting to run

This commit is contained in:
2026-08-08 14:43:59 +08:00
commit 852613586f
19 changed files with 3455 additions and 0 deletions
+684
View File
@@ -0,0 +1,684 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/joho/godotenv"
markdown "github.com/markdownit/markdown-it-go"
)
var (
db *sql.DB
sessionStore *sessions.CookieStore
markdownParser *markdown.MarkdownIt
)
// Models
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Role string `json:"role"` // admin, author, reader
CreatedAt time.Time `json:"created_at"`
}
type Post struct {
ID string `json:"id"`
AuthorID string `json:"author_id"`
Title string `json:"title"`
Slug string `json:"slug"`
Content string `json:"content"`
Excerpt string `json:"excerpt"`
Type string `json:"type"` // post, micro
Category string `json:"category"`
Tags []string `json:"tags"`
Published bool `json:"published"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Comment struct {
ID string `json:"id"`
PostID string `json:"post_id"`
AuthorID string `json:"author_id"`
Content string `json:"content"`
Approved bool `json:"approved"`
CreatedAt time.Time `json:"created_at"`
}
type APIResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
func init() {
// Load environment variables
godotenv.Load()
// Initialize markdown parser
markdownParser = markdown.New()
// Initialize session store
sessionStore = sessions.NewCookieStore([]byte(getEnv("SESSION_KEY", "dev-session-key")))
}
func main() {
// Database connection
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true",
getEnv("DB_USER", "root"),
getEnv("DB_PASSWORD", "password"),
getEnv("DB_HOST", "mariadb"),
getEnv("DB_PORT", "3306"),
getEnv("DB_NAME", "blogging_cms"),
)
var err error
db, err = sql.Open("mysql", dsn)
if err != nil {
log.Fatalf("Database connection failed: %v", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("Database ping failed: %v", err)
}
log.Println("Database connected successfully")
// Initialize database schema
if err := initDB(); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// Router setup
router := mux.NewRouter()
// Static files
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
// Public routes
router.HandleFunc("/", handleHome).Methods("GET")
router.HandleFunc("/post/{slug}", handleViewPost).Methods("GET")
router.HandleFunc("/category/{category}", handleCategory).Methods("GET")
router.HandleFunc("/feed", handleRSSFeed).Methods("GET")
router.HandleFunc("/api/posts", handleGetPosts).Methods("GET")
router.HandleFunc("/api/posts/{id}", handleGetPost).Methods("GET")
router.HandleFunc("/api/search", handleSearch).Methods("GET")
// Auth routes
router.HandleFunc("/auth/register", handleRegister).Methods("POST")
router.HandleFunc("/auth/login", handleLogin).Methods("POST")
router.HandleFunc("/auth/logout", handleLogout).Methods("POST")
// Protected routes
router.HandleFunc("/dashboard", handleDashboard).Methods("GET")
router.HandleFunc("/api/posts", handleCreatePost).Methods("POST")
router.HandleFunc("/api/posts/{id}", handleUpdatePost).Methods("PUT")
router.HandleFunc("/api/posts/{id}", handleDeletePost).Methods("DELETE")
router.HandleFunc("/api/comments", handleCreateComment).Methods("POST")
// Middleware
router.Use(loggingMiddleware)
port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, router))
}
func initDB() error {
schema := `
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'author',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS posts (
id VARCHAR(36) PRIMARY KEY,
author_id VARCHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
content LONGTEXT NOT NULL,
excerpt VARCHAR(500),
type VARCHAR(50) DEFAULT 'post',
category VARCHAR(100),
published BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (author_id) REFERENCES users(id),
INDEX (slug),
INDEX (published),
INDEX (created_at),
INDEX (category)
);
CREATE TABLE IF NOT EXISTS tags (
id VARCHAR(36) PRIMARY KEY,
post_id VARCHAR(36) NOT NULL,
tag VARCHAR(100),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS comments (
id VARCHAR(36) PRIMARY KEY,
post_id VARCHAR(36) NOT NULL,
author_id VARCHAR(36),
content TEXT NOT NULL,
approved BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
);
`
for _, statement := range strings.Split(schema, ";") {
statement = strings.TrimSpace(statement)
if statement != "" {
if _, err := db.Exec(statement); err != nil {
return fmt.Errorf("failed to create table: %w", err)
}
}
}
return nil
}
// Handlers
func handleHome(w http.ResponseWriter, r *http.Request) {
rows, err := db.Query(`
SELECT id, author_id, title, slug, excerpt, type, created_at
FROM posts
WHERE published = TRUE
ORDER BY created_at DESC
LIMIT 20
`)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var posts []Post
for rows.Next() {
var post Post
rows.Scan(&post.ID, &post.AuthorID, &post.Title, &post.Slug, &post.Excerpt, &post.Type, &post.CreatedAt)
posts = append(posts, post)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
renderTemplate(w, "home.html", map[string]interface{}{
"posts": posts,
})
}
func handleViewPost(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
slug := vars["slug"]
var post Post
var authorUsername string
var commentCount int
err := db.QueryRow(`
SELECT p.id, p.author_id, p.title, p.slug, p.content, p.excerpt, p.type, p.category, p.created_at, p.updated_at, u.username
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.slug = ? AND p.published = TRUE
`, slug).Scan(&post.ID, &post.AuthorID, &post.Title, &post.Slug, &post.Content, &post.Excerpt, &post.Type, &post.Category, &post.CreatedAt, &post.UpdatedAt, &authorUsername)
if err != nil {
http.NotFound(w, r)
return
}
// Render markdown
post.Content = markdownParser.Render(post.Content)
// Get tags
tagRows, _ := db.Query("SELECT tag FROM tags WHERE post_id = ?", post.ID)
defer tagRows.Close()
for tagRows.Next() {
var tag string
tagRows.Scan(&tag)
post.Tags = append(post.Tags, tag)
}
// Get comments
commentRows, _ := db.Query("SELECT COUNT(*) FROM comments WHERE post_id = ? AND approved = TRUE", post.ID)
commentRows.Next()
commentRows.Scan(&commentCount)
commentRows.Close()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
renderTemplate(w, "post.html", map[string]interface{}{
"post": post,
"author": authorUsername,
"commentCount": commentCount,
})
}
func handleCategory(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
category := vars["category"]
rows, err := db.Query(`
SELECT id, author_id, title, slug, excerpt, type, created_at
FROM posts
WHERE published = TRUE AND category = ?
ORDER BY created_at DESC
`, category)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var posts []Post
for rows.Next() {
var post Post
rows.Scan(&post.ID, &post.AuthorID, &post.Title, &post.Slug, &post.Excerpt, &post.Type, &post.CreatedAt)
posts = append(posts, post)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
renderTemplate(w, "category.html", map[string]interface{}{
"category": category,
"posts": posts,
})
}
func handleGetPosts(w http.ResponseWriter, r *http.Request) {
pageStr := r.URL.Query().Get("page")
page := 1
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
limit := 20
offset := (page - 1) * limit
rows, err := db.Query(`
SELECT id, author_id, title, slug, excerpt, type, category, created_at
FROM posts
WHERE published = TRUE
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`, limit, offset)
if err != nil {
respondJSON(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Database error"})
return
}
defer rows.Close()
var posts []Post
for rows.Next() {
var post Post
rows.Scan(&post.ID, &post.AuthorID, &post.Title, &post.Slug, &post.Excerpt, &post.Type, &post.Category, &post.CreatedAt)
posts = append(posts, post)
}
respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: posts})
}
func handleGetPost(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
postID := vars["id"]
var post Post
err := db.QueryRow(`
SELECT id, author_id, title, slug, content, excerpt, type, category, published, created_at, updated_at
FROM posts
WHERE id = ?
`, postID).Scan(&post.ID, &post.AuthorID, &post.Title, &post.Slug, &post.Content, &post.Excerpt, &post.Type, &post.Category, &post.Published, &post.CreatedAt, &post.UpdatedAt)
if err != nil {
respondJSON(w, http.StatusNotFound, APIResponse{Success: false, Message: "Post not found"})
return
}
respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: post})
}
func handleCreatePost(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value("userID").(string)
if !ok {
respondJSON(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Unauthorized"})
return
}
var req struct {
Title string `json:"title"`
Content string `json:"content"`
Excerpt string `json:"excerpt"`
Type string `json:"type"`
Category string `json:"category"`
Tags []string `json:"tags"`
Published bool `json:"published"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
return
}
postID := uuid.New().String()
slug := slugify(req.Title)
_, err := db.Exec(`
INSERT INTO posts (id, author_id, title, slug, content, excerpt, type, category, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, postID, userID, req.Title, slug, req.Content, req.Excerpt, req.Type, req.Category, req.Published)
if err != nil {
respondJSON(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to create post"})
return
}
// Save tags
for _, tag := range req.Tags {
db.Exec("INSERT INTO tags (id, post_id, tag) VALUES (?, ?, ?)", uuid.New().String(), postID, tag)
}
respondJSON(w, http.StatusCreated, APIResponse{Success: true, Message: "Post created", Data: map[string]string{"id": postID}})
}
func handleUpdatePost(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
postID := vars["id"]
userID, ok := r.Context().Value("userID").(string)
if !ok {
respondJSON(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Unauthorized"})
return
}
var req struct {
Title string `json:"title"`
Content string `json:"content"`
Excerpt string `json:"excerpt"`
Type string `json:"type"`
Category string `json:"category"`
Tags []string `json:"tags"`
Published bool `json:"published"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
return
}
// Verify ownership
var authorID string
db.QueryRow("SELECT author_id FROM posts WHERE id = ?", postID).Scan(&authorID)
if authorID != userID {
respondJSON(w, http.StatusForbidden, APIResponse{Success: false, Message: "Forbidden"})
return
}
slug := slugify(req.Title)
_, err := db.Exec(`
UPDATE posts
SET title = ?, slug = ?, content = ?, excerpt = ?, type = ?, category = ?, published = ?
WHERE id = ?
`, req.Title, slug, req.Content, req.Excerpt, req.Type, req.Category, req.Published, postID)
if err != nil {
respondJSON(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to update post"})
return
}
// Update tags
db.Exec("DELETE FROM tags WHERE post_id = ?", postID)
for _, tag := range req.Tags {
db.Exec("INSERT INTO tags (id, post_id, tag) VALUES (?, ?, ?)", uuid.New().String(), postID, tag)
}
respondJSON(w, http.StatusOK, APIResponse{Success: true, Message: "Post updated"})
}
func handleDeletePost(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
postID := vars["id"]
userID, ok := r.Context().Value("userID").(string)
if !ok {
respondJSON(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Unauthorized"})
return
}
var authorID string
db.QueryRow("SELECT author_id FROM posts WHERE id = ?", postID).Scan(&authorID)
if authorID != userID {
respondJSON(w, http.StatusForbidden, APIResponse{Success: false, Message: "Forbidden"})
return
}
_, err := db.Exec("DELETE FROM posts WHERE id = ?", postID)
if err != nil {
respondJSON(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete post"})
return
}
respondJSON(w, http.StatusOK, APIResponse{Success: true, Message: "Post deleted"})
}
func handleCreateComment(w http.ResponseWriter, r *http.Request) {
var req struct {
PostID string `json:"post_id"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
return
}
commentID := uuid.New().String()
_, err := db.Exec(`
INSERT INTO comments (id, post_id, content, approved)
VALUES (?, ?, ?, FALSE)
`, commentID, req.PostID, req.Content)
if err != nil {
respondJSON(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to create comment"})
return
}
respondJSON(w, http.StatusCreated, APIResponse{Success: true, Message: "Comment submitted for moderation"})
}
func handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
respondJSON(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Search query required"})
return
}
searchTerm := "%" + query + "%"
rows, err := db.Query(`
SELECT id, author_id, title, slug, excerpt, type, created_at
FROM posts
WHERE published = TRUE AND (title LIKE ? OR content LIKE ? OR excerpt LIKE ?)
ORDER BY created_at DESC
LIMIT 50
`, searchTerm, searchTerm, searchTerm)
if err != nil {
respondJSON(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Search failed"})
return
}
defer rows.Close()
var posts []Post
for rows.Next() {
var post Post
rows.Scan(&post.ID, &post.AuthorID, &post.Title, &post.Slug, &post.Excerpt, &post.Type, &post.CreatedAt)
posts = append(posts, post)
}
respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: posts})
}
func handleRSSFeed(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
rows, _ := db.Query(`
SELECT id, title, slug, excerpt, created_at
FROM posts
WHERE published = TRUE
ORDER BY created_at DESC
LIMIT 20
`)
defer rows.Close()
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Blog</title>
<link>`)
fmt.Fprint(w, getEnv("SITE_URL", "http://localhost:8080"))
fmt.Fprint(w, `</link>
<description>Latest posts</description>
`)
for rows.Next() {
var id, title, slug, excerpt string
var createdAt time.Time
rows.Scan(&id, &title, &slug, &excerpt, &createdAt)
fmt.Fprintf(w, `<item>
<title>%s</title>
<link>%s/post/%s</link>
<description>%s</description>
<pubDate>%s</pubDate>
</item>
`, title, getEnv("SITE_URL", "http://localhost:8080"), slug, excerpt, createdAt.Format(time.RFC1123Z))
}
fmt.Fprint(w, `</channel>
</rss>`)
}
func handleRegister(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
json.NewDecoder(r.Body).Decode(&req)
userID := uuid.New().String()
passwordHash := hashPassword(req.Password)
_, err := db.Exec(`
INSERT INTO users (id, username, email, password_hash, role)
VALUES (?, ?, ?, ?, 'author')
`, userID, req.Username, req.Email, passwordHash)
if err != nil {
respondJSON(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Registration failed"})
return
}
respondJSON(w, http.StatusCreated, APIResponse{Success: true, Message: "User created"})
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
json.NewDecoder(r.Body).Decode(&req)
var userID, passwordHash string
err := db.QueryRow("SELECT id, password_hash FROM users WHERE email = ?", req.Email).Scan(&userID, &passwordHash)
if err != nil || !verifyPassword(req.Password, passwordHash) {
respondJSON(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid credentials"})
return
}
session, _ := sessionStore.Get(r, "auth")
session.Values["userID"] = userID
session.Save(r, w)
respondJSON(w, http.StatusOK, APIResponse{Success: true, Message: "Logged in"})
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
session, _ := sessionStore.Get(r, "auth")
session.Options.MaxAge = -1
session.Save(r, w)
respondJSON(w, http.StatusOK, APIResponse{Success: true, Message: "Logged out"})
}
func handleDashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
renderTemplate(w, "dashboard.html", nil)
}
// Utility functions
func getEnv(key, defaultVal string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultVal
}
func slugify(s string) string {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(s), " ", "-"))
}
func hashPassword(password string) string {
// In production, use bcrypt
return password
}
func verifyPassword(password, hash string) bool {
// In production, use bcrypt
return password == hash
}
func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(data)
}
func renderTemplate(w http.ResponseWriter, name string, data interface{}) {
t, err := template.ParseFiles("templates/" + name)
if err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
t.Execute(w, data)
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s %s", r.Method, r.RequestURI, r.RemoteAddr)
next.ServeHTTP(w, r)
})
}