Skip to content

mplsllc/cms

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MPLS CMS

A Go CMS framework built for sites that can't afford to go down.

Go License Release


MPLS CMS is a modular Go framework for building content sites. It ships with a plugin system, a schema-driven content layer, a 4-layer template engine, and security defaults that most CMS frameworks treat as optional extras. Server-rendered, no JavaScript build tools, single binary deployment.

Built originally for Mercado Media Productions — an independent journalist covering ICE raids and protests in Minneapolis. It runs in production with 7 first-party plugins.


Why Go has no serious CMS framework

WordPress Ghost Hugo PocketBase MPLS CMS
Language PHP Node.js Go Go Go
Rendering Server Server Static API-first Server
Database MySQL SQLite/MySQL None SQLite PostgreSQL
Plugin system Yes Limited No No Yes
Multi-user admin Yes Yes No Yes Yes
Security defaults Plugins Basic N/A Basic Built-in
Single binary No No Yes Yes Yes

WordPress has the ecosystem. Hugo has the speed. Nothing in Go has both a plugin system and a proper editorial CMS. MPLS CMS fills that gap.


Features

Core

  • Three-phase module lifecycle: resolve dependency DAG → run plugin migrations → register routes
  • Plugin system via optional interfaces — no callbacks, no event bus, just type assertions
  • Schema-driven content types: define a ContentType in Go, get migration SQL, repository, admin CRUD, and REST API generated automatically
  • 4-layer template loader: project overrides > theme > plugins > framework defaults
  • Theme system with theme.json manifest, CSS variable injection, FuncMap functions

Auth & Security

  • Redis-backed sessions with 32-byte crypto/rand tokens, HttpOnly + Secure + SameSite cookies
  • bcrypt cost 12, TOTP 2FA with AES-256-GCM encrypted secrets, QR code enrollment
  • CSRF double-submit cookie on all forms and admin API endpoints
  • Per-route rate limiting via Redis sliding window
  • Strict CSP built from config slices — no hardcoded domains
  • Password reset with hashed tokens, 1-hour expiry, anti-enumeration response

Content

  • TipTap rich text editor with autosave, media library picker, SERP preview
  • Scheduled publishing with background poller
  • Slug generation with collision handling
  • bluemonday HTML sanitization on all editor output

Infrastructure

  • PostgreSQL 16 via pgx/v5 with connection pooling
  • Redis 7 for sessions, rate limiting, page cache, SSE fanout
  • Cloudflare R2 / S3 file uploads with local disk fallback
  • Server-sent events hub with topic-based pub/sub, nil-safe, slow-client eviction
  • fw CLI for project scaffolding and content type code generation
  • Docker Compose deployment, systemd service, nginx + Let's Encrypt reference config

Quick start

go install github.com/mplsllc/cms/cmd/fw@latest
fw new mysite
cd mysite
cp .env.example .env   # set DATABASE_URL, REDIS_ADDR, SESSION_SECRET
go run ./cmd/server     # opens setup wizard at localhost:8080

The setup wizard creates your admin account on first run — no CLI user creation step needed.

Add a content type:

fw content Article title:text body:richtext author:relation:users published_at:datetime

This generates a migration file, model struct, repository, admin handlers, and templates — wired and ready to register.


Architecture

The Module interface

Every plugin, theme, and feature is a Module:

type Module interface {
    Name() string
    Dependencies() []string  // topological sort, cycle detection
    Register(app *App) error // fatal by default, optional via RegisterOptional
}

Plugins implement additional optional interfaces as needed — the framework checks with type assertions, not reflection:

type PluginWithNav interface {
    NavItems() []NavItem
}
type PluginWithDashboard interface {
    DashboardStats(ctx context.Context) []DashboardStat
}
type PluginWithMigrations interface {
    Migrations() []Migration
}
type PluginWithTemplates interface {
    TemplateFS() fs.FS
}
type PluginWithBeforeRender interface {
    OnBeforeRender(c *gin.Context, name string, data gin.H)
}
type PluginWithPostSaved interface {
    OnPostSaved(ctx context.Context, event PostSavedEvent) error
}
type PluginWithPostDeleted interface {
    OnPostDeleted(ctx context.Context, postID uuid.UUID) error
}
type PluginWithAdminPostEditLoad interface {
    OnAdminPostEditLoad(c *gin.Context, postID uuid.UUID, data gin.H)
}

A plugin in one file

package newsletter

type Plugin struct{ repo *Repository }

func New() *Plugin { return &Plugin{} }

func (p *Plugin) Name() string           { return "newsletter" }
func (p *Plugin) Dependencies() []string { return nil }

func (p *Plugin) Register(app *app.App) error {
    p.repo = NewRepository(app.Pool())
    app.OnBeforeServe(func(a *app.App, r *gin.Engine) {
        r.POST("/api/newsletter/subscribe", p.Subscribe)
        admin := r.Group("/admin/newsletter", middleware.AuthMiddleware(a.Sessions(), true))
        admin.GET("", p.List)
        admin.GET("/export", p.Export)
    })
    return nil
}

func (p *Plugin) NavItems() []app.NavItem {
    return []app.NavItem{{Section: "Audience", Label: "Newsletter", URL: "/admin/newsletter"}}
}

func (p *Plugin) Migrations() []app.Migration {
    return []app.Migration{{Key: "newsletter:001_create_table", SQL: createTableSQL}}
}

//go:embed all:templates
var templateFS embed.FS
func (p *Plugin) TemplateFS() fs.FS { return templateFS }

Register it in one line:

a.RegisterModule(newsletter.New())

Template layering

Templates resolve in priority order. Earlier layers win on name collision:

1. web/templates/        ← project overrides (always wins)
2. themes/active/        ← active theme defaults
3. plugin embed.FS       ← plugin-provided templates
4. framework/templates/  ← framework defaults (lowest priority)

A project that wants a custom comment form just drops plugins/comments/widget.html into web/templates/ — no plugin code changes needed.

Schema-driven content

var Article = schema.ContentType{
    Name:       "Article",
    Table:      "articles",
    HasSlug:    true,
    Timestamps: true,
    Fields: []schema.Field{
        {Name: "Title",    Column: "title",     Type: schema.FieldText,     Required: true, MaxLength: 200},
        {Name: "Body",     Column: "body",      Type: schema.FieldRichText},
        {Name: "AuthorID", Column: "author_id", Type: schema.FieldRelation, Required: true},
        {Name: "Status",   Column: "status",    Type: schema.FieldEnum,     Enum: []string{"draft", "published"}},
    },
}

From this definition the framework generates:

  • CREATE TABLE articles (...) with appropriate column types and constraints
  • ContentRepository with GetByID, GetBySlug, List (paginated + filterable), Create, Update, Delete
  • Admin handlers for list, create, edit, update, delete
  • JSON REST API endpoints
  • Validation that collects all errors before returning (not short-circuit)

First-party plugins

Plugin Description Repo
cms-newsletter Email subscriber list with admin export mplsllc/cms-newsletter
cms-seo Sitemap, robots.txt, meta tags, JSON-LD structured data mplsllc/cms-seo
cms-comments Threaded comments with moderation queue and ALTCHA anti-spam mplsllc/cms-comments
cms-search PostgreSQL full-text search with weighted tsvector and snippets mplsllc/cms-search
cms-redirects 301/302 redirect management with in-memory lookup mplsllc/cms-redirects
cms-analytics Content performance dashboard (post views, trends, cross-plugin stats) mplsllc/cms-analytics

Project structure

mysite/
  cmd/
    server/main.go       ← config → app → register modules → start
    create-admin/        ← CLI to create the initial admin user
  internal/
    models/              ← app-specific structs
    repository/          ← hand-written queries for complex content types
    api/
      handlers/          ← Gin handlers
      router.go          ← route registration
    plugins/             ← plugins in development before extraction
  web/
    templates/           ← project overrides (layer 1)
    static/              ← CSS, JS, images
  themes/
    default/             ← active theme (layer 2)
  migrations/            ← numbered SQL migration files
  .env.example
  Makefile
  docker-compose.yml

Configuration

All config via environment variables. Essential ones:

Variable Description Default
DATABASE_URL PostgreSQL connection string required
REDIS_ADDR Redis address localhost:6379
SESSION_SECRET 32+ byte random string required
CSRF_SECRET 32+ byte random string required
TOTP_ENCRYPTION_KEY 32-byte key for TOTP secret encryption required
SITE_NAME Site name used in templates and admin My Site
APP_BASE_URL Full base URL with scheme http://localhost:8080
SMTP_HOST SMTP server for outbound email localhost
UPLOAD_PATH Local file upload directory ./uploads
ACTIVE_THEME Theme directory name under themes/ default
REDIS_PREFIX Prefix for all Redis keys ``

Generate secrets:

openssl rand -hex 32   # SESSION_SECRET
openssl rand -hex 32   # CSRF_SECRET
openssl rand -hex 32   # TOTP_ENCRYPTION_KEY

Documentation


Requirements

  • Go 1.24+
  • PostgreSQL 14+ (16 recommended — CREATE OR REPLACE TRIGGER requires 14+)
  • Redis 7+

License

MPLS License — Principled Libre Software. Free to use, modify, and distribute. Cannot be used to build proprietary SaaS products without a commercial license.


Built by

MPLS LLC — Minneapolis, MN.

Built for Mercado Media Productions, an independent journalism operation run by US Army veteran Andrew Mercado covering ICE raids, protests, and breaking news in the Twin Cities. The security defaults, plugin isolation, and journalist-specific threat model shaped every architectural decision.

About

MPLS CMS — a Go/Gin content management framework

Resources

License

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors