package main import ( "database/sql" "encoding/json" "fmt" "html/template" "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" "github.com/gomarkdown/markdown" ) var ( db *sql.DB sessionStore *sessions.CookieStore ) // 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 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 = string(markdown.ToHTML([]byte(post.Content), nil, nil)) // 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, ` Blog `) fmt.Fprint(w, getEnv("SITE_URL", "http://localhost:8080")) fmt.Fprint(w, ` Latest posts `) for rows.Next() { var id, title, slug, excerpt string var createdAt time.Time rows.Scan(&id, &title, &slug, &excerpt, &createdAt) fmt.Fprintf(w, ` %s %s/post/%s %s %s `, title, getEnv("SITE_URL", "http://localhost:8080"), slug, excerpt, createdAt.Format(time.RFC1123Z)) } fmt.Fprint(w, ` `) } 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) }) }