cms

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 17, 2026 License: MIT Imports: 36 Imported by: 0

README

Sigocu — Go CMS

Go Reference

Sigocu mounts a content manager inside an existing Go application. It uses your SQL connection, embeds its admin assets, and compiles into your application's executable.

Built-in login and a Mount helper are provided. Both are optional: supply an authentication adapter and/or mount Sigocu as a regular http.Handler instead.

Implemented:

  • SQLite and PostgreSQL through database/sql, without taking ownership of your pool.
  • Explicit Go model definitions and generated CRUD forms.
  • Text, Markdown rich text, images, integers, exact decimals, booleans, dates, URLs, slugs, and choices.
  • Singleton settings, roles, revision conflicts, projected reads, and cursor pagination.
  • SQL-backed local users and sessions, password hashing, CSRF protection, and bounded login work.
  • Persistent local image storage through an optional adapter.

Requires Go 1.26 or newer. No Node.js, frontend build, separate CMS server, or Redis is required.

Try the implemented example

From this repository, bootstrap an administrator explicitly:

go run ./examples/basic -bootstrap

Set SIGOCU_ADMIN_PASSWORD in your environment first, using a password of at least 12 bytes. There are no default credentials. The bootstrap command exits with a conflict if an account already exists; it never resets an account.

Then start the local HTTP demo:

go run ./examples/basic -http-dev

Open http://127.0.0.1:8080/admin/ to sign in as admin. Create a post with a title and slug, then visit http://127.0.0.1:8080/ to see it. The example stores SQL data in sigocu.db and images in uploads/.

-http-dev allows cookies over HTTP for local development. Production uses the default secure cookies behind HTTPS.

Add it to an application

admin, err := cms.New(cms.Config{
    DB:      existingDB,
    Dialect: cms.Postgres, // or cms.SQLite
    Models: []cms.Model{{
        Name: "posts",
        Fields: []cms.Field{
            {Name: "title", Type: cms.Text, Required: true},
            {Name: "body", Type: cms.RichText},
        },
    }},
})
if err != nil {
    return err
}
if err := admin.Migrate(ctx); err != nil {
    return err
}
admin.Mount(existingMux) // default: /admin; built-in login enabled

Import the module as cms "github.com/rubberpirate/sigocu". The host supplies its SQL driver and owns its server, router, migrations stage, database pool, and shutdown.

Bootstrap the first administrator through BootstrapAdmin in a one-time application command. For an application with existing authentication, configure Authenticate instead; local login is then disabled.

Keep the website's design

Declare the editable fields in a schema, then load their values into the existing Go view struct:

record, err := admin.Singleton(ctx, "homepage")
if err != nil {
    return err
}
if err := record.Decode(&existingHomeView); err != nil {
    return err
}
// Render existingHomeView through the website's current template.

JSON tags map schema names onto existing Go fields. Sigocu generates admin forms; the website keeps its templates, classes, CSS, and components. Hardcoded text needs a one-time template variable where it should become editable. Use schema length limits to fit the existing layout.

Follow the design-preserving integration walkthrough for a complete schema, typed variables, original-content fallbacks, and JavaScript frontend guidance.

Read the integration guide for complete setup, host authentication, custom mounting, model definitions, public content reads, media, deployment, and troubleshooting. See the measured performance notes for benchmark commands and limits.

Go reference and publishing

The Go reference is generated from package comments, exported API comments, and tested examples. The publishing guide explains the public repository, version tag, module proxy, and pkg.go.dev indexing steps.

Relations and drafts/publishing remain P1. Version history, localization, and live website previews remain later work. Current saves become immediately available to the application's read API.

License

Released under the MIT license.

Documentation

Overview

Package cms embeds Sigocu, a schema-driven content manager, into an existing Go HTTP application without replacing its website templates or styles.

Integration

Declare editable collections and singleton settings with Model and Field, then call New with the application's existing database/sql connection pool. Run CMS.Migrate in the application's migration stage and CMS.CheckSchema on subsequent web-process startups. Neither New nor model registration changes existing application tables. The host retains ownership of its pool and server.

CMS.Mount registers the admin on an existing http.ServeMux. The default path is /admin; set Config.BasePath to use another unused prefix. Mount is optional: CMS implements http.Handler and can be passed to another router directly, preserving its configured path. Admin assets are embedded in the executable and served only through the admin routes.

Keep the website's design

A schema defines editable data and generated admin forms. It does not generate or modify the public website. Read values with CMS.Singleton, CMS.Get, CMS.GetBySlug, or CMS.List and pass them into the application's existing templates or frontend response objects. Existing HTML, CSS, components, and routes remain controlled by the host. Only values chosen by the developer become editable; adding a schema does not discover hardcoded template text. Record.Decode binds schema values to an existing Go view struct using normal JSON tags, so template field names can stay the same.

An unsaved singleton returns ErrNotFound. The host can retain its existing text as a fallback while content is introduced. Plain strings should remain escaped template data; use RenderRichText to render a Markdown field safely. Go read methods are trusted application APIs and do not mount a public REST endpoint or authorize website visitors automatically.

Authentication and media

Built-in SQL-backed authentication is enabled unless Config.Authenticate is provided. Create the first administrator with CMS.BootstrapAdmin in an explicit application command. There are no default credentials. Supplying a host authenticator disables built-in login; Config.Authorize can adapt the host's permissions as well.

Images require a MediaStorage adapter. The media/local subpackage provides persistent filesystem storage. CMS.Mount also mounts the configured public image path when media is enabled; applications mounting CMS manually can mount CMS.MediaHandler separately. Private attachments are outside this handler's scope. The host owns persistence, backups, and adapter shutdown.

Consistency and limits

CMS.Update and CMS.Delete require an expected revision and return ErrConflict for stale writes. Query uses bounded cursor pagination and defaults to metadata-only results. Select Fields explicitly for public lists. Saves are immediately visible; drafts, publishing workflows, relations, and historical versions are not implemented in this core.

See the integration guide, runnable example, and publishing guide for complete host-application and release workflows.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotFound indicates that a requested model, record, slug, or image is missing.
	ErrNotFound = errors.New("cms: not found")
	// ErrConflict indicates a stale revision or a duplicate record/slug key.
	ErrConflict = errors.New("cms: record changed or key already exists")
	// ErrUnauthenticated indicates that no usable editor identity was provided.
	ErrUnauthenticated = errors.New("cms: authentication required")
	// ErrForbidden indicates that the actor lacks permission for an action.
	ErrForbidden = errors.New("cms: permission denied")
	// ErrInvalid indicates malformed configuration or invalid content values.
	ErrInvalid = errors.New("cms: invalid input")
	// ErrTooLarge indicates a record or request exceeding a configured package limit.
	ErrTooLarge = errors.New("cms: payload too large")
)
View Source
var ErrRateLimited = errors.New("cms: too many login attempts")

ErrRateLimited indicates that the bounded local-login allowance was exhausted.

Functions

func RenderRichText

func RenderRichText(source string) (template.HTML, error)

RenderRichText renders Markdown with raw HTML and dangerous URL schemes disabled. Only its output should be treated as trusted template.HTML.

Types

type Action

type Action string

Action identifies an operation for an authorization policy.

const (
	// Read allows reading admin content, including collection lists.
	Read Action = "read"
	// Create allows creating records.
	Create Action = "create"
	// Update allows modifying records.
	Update Action = "update"
	// Delete allows deleting collection records.
	Delete Action = "delete"
	// Upload allows adding media.
	Upload Action = "upload"
	// ManageUsers allows local account administration through trusted Go APIs.
	ManageUsers Action = "manage_users"
)

type CMS

type CMS struct {
	// contains filtered or unexported fields
}

CMS manages content and implements http.Handler for its admin subtree. A constructed instance is safe for concurrent use. The host owns its server, database pool, and media adapter lifecycle.

func New

func New(cfg Config) (*CMS, error)

New compiles immutable models and templates. It performs no database I/O. Without Authenticate, Sigocu's own SQL-backed login is enabled.

Example
package main

import (
	"context"
	"database/sql"
	"fmt"
	"net/http"

	cms "github.com/rubberpirate/sigocu"
	_ "modernc.org/sqlite"
)

func main() {
	// The host chooses its driver and owns this connection pool.
	db, err := sql.Open("sqlite", ":memory:")
	if err != nil {
		panic(err)
	}
	defer db.Close()
	admin, err := cms.New(cms.Config{
		DB: db, Dialect: cms.SQLite, BasePath: "/cms",
		Models: []cms.Model{{
			Name: "homepage", Singleton: true,
			Fields: []cms.Field{{Name: "heading", Type: cms.Text, Required: true}},
		}},
	})
	if err != nil {
		panic(err)
	}
	if err = admin.Migrate(context.Background()); err != nil {
		panic(err)
	}
	// Bootstrap the first editor in a separate application command, or provide
	// Config.Authenticate to use the host's existing login.
	mux := http.NewServeMux()
	admin.Mount(mux)
	fmt.Println(admin.BasePath())
}
Output:
/cms

func (*CMS) BasePath

func (c *CMS) BasePath() string

BasePath returns the normalized admin mount path.

func (*CMS) BootstrapAdmin

func (c *CMS) BootstrapAdmin(ctx context.Context, username, password string) error

BootstrapAdmin creates the first local administrator. It never resets an existing account and is intentionally unavailable as an HTTP endpoint.

func (*CMS) CheckSchema

func (c *CMS) CheckSchema(ctx context.Context) error

CheckSchema verifies the installed schema without performing DDL. Call it on web-process startup when migrations run in a separate deployment stage.

func (*CMS) CleanExpiredSessions

func (c *CMS) CleanExpiredSessions(ctx context.Context) error

CleanExpiredSessions removes one bounded batch; no background worker is started.

func (*CMS) Create

func (c *CMS) Create(ctx context.Context, actor Principal, model string, input Values) (Record, error)

Create authorizes actor and creates a validated record with revision 1. For a singleton, only one record can exist. Duplicate IDs/slugs return ErrConflict. Actor must come from trusted host code, not a browser-supplied identity.

func (*CMS) CreateUser

func (c *CMS) CreateUser(ctx context.Context, actor Principal, username, password, role string) (string, error)

CreateUser is a trusted application API; actor is supplied by host code.

func (*CMS) Delete

func (c *CMS) Delete(ctx context.Context, actor Principal, model, id string, expected int64) error

Delete authorizes actor and deletes a collection record at the expected revision. Singletons cannot be deleted; media references do not delete files.

func (*CMS) DisableUser

func (c *CMS) DisableUser(ctx context.Context, actor Principal, id string, disabled bool) error

DisableUser changes an account's disabled flag and revokes its sessions. Actor must have ManageUsers permission. It does not delete account data.

func (*CMS) Get

func (c *CMS) Get(ctx context.Context, model, id string) (Record, error)

Get retrieves a complete record by model and ID. Missing records return ErrNotFound. This is a trusted host API; it does not authorize website visitors.

func (*CMS) GetBySlug

func (c *CMS) GetBySlug(ctx context.Context, model, slug string) (Record, error)

GetBySlug retrieves a complete record using its model's configured SlugField. It returns ErrNotFound when the slug or slug-enabled model is unavailable.

func (*CMS) ImageURL

func (c *CMS) ImageURL(id string) string

ImageURL returns an image's public path, or an empty string for an invalid ID or disabled media. It does not query SQL to confirm the image exists.

func (*CMS) List

func (c *CMS) List(ctx context.Context, model string, q Query) (Page, error)

List retrieves a bounded page, ordered by descending creation time and ID. Values are omitted unless Fields or AllFields is requested. Pass NextCursor as After with the same model and projection to continue; no count query runs.

Example
package main

import (
	"context"
	"database/sql"
	"fmt"

	cms "github.com/rubberpirate/sigocu"
	_ "modernc.org/sqlite"
)

func main() {
	db, err := sql.Open("sqlite", ":memory:")
	if err != nil {
		panic(err)
	}
	defer db.Close()
	admin, err := cms.New(cms.Config{DB: db, Dialect: cms.SQLite, Models: []cms.Model{{
		Name: "posts", Fields: []cms.Field{{Name: "title", Type: cms.Text, Required: true}, {Name: "excerpt", Type: cms.Textarea}},
	}}})
	if err != nil {
		panic(err)
	}
	ctx := context.Background()
	if err = admin.Migrate(ctx); err != nil {
		panic(err)
	}
	// A trusted application command supplies the actor; browser input must not.
	actor := cms.Principal{ID: "seed-command", Roles: []string{"admin"}}
	if _, err = admin.Create(ctx, actor, "posts", cms.Values{"title": "First post", "excerpt": "A short introduction."}); err != nil {
		panic(err)
	}
	page, err := admin.List(ctx, "posts", cms.Query{Fields: []string{"excerpt"}})
	if err != nil {
		panic(err)
	}
	fmt.Println(page.Items[0].Title)
	fmt.Println(page.Items[0].Values["excerpt"])
}
Output:
First post
A short introduction.

func (*CMS) MediaHandler

func (c *CMS) MediaHandler() http.Handler

MediaHandler serves finalized images publicly under MediaBasePath. Mount calls this automatically; callers using ServeHTTP directly may mount it themselves.

func (*CMS) Migrate

func (c *CMS) Migrate(ctx context.Context) error

Migrate creates and verifies CMS-owned SQL tables without changing application tables. Run it before serving traffic, or in a designated deployment stage. Custom repositories own their migrations and cannot use this method.

func (*CMS) Mount

func (c *CMS) Mount(mux *http.ServeMux)

Mount registers the exact base path and its subtree on the host's mux. Like ServeMux.Handle, it panics if the host already registered these patterns. Mount is optional: CMS also implements http.Handler directly.

func (*CMS) ServeHTTP

func (c *CMS) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP serves only the configured admin path and its subtree. Pass the original path without stripping BasePath. Website routes and public media are separate; Mount is a convenience for registering the relevant handlers.

func (*CMS) SetPassword

func (c *CMS) SetPassword(ctx context.Context, actor Principal, id, password string) error

SetPassword revokes all sessions for the account in the same transaction.

func (*CMS) SetRole

func (c *CMS) SetRole(ctx context.Context, actor Principal, id, role string) error

SetRole changes a local account's role to admin, editor, or viewer and revokes its sessions. Actor must have ManageUsers permission.

func (*CMS) Singleton

func (c *CMS) Singleton(ctx context.Context, model string) (Record, error)

Singleton retrieves a singleton model's saved record. Before its first save, it returns ErrNotFound; field defaults are not persisted implicitly on reads.

func (*CMS) Update

func (c *CMS) Update(ctx context.Context, actor Principal, model, id string, expected int64, input Values) (Record, error)

Update authorizes actor and applies a partial update at the expected revision. Omitted and unregistered stored fields are preserved. A stale revision or conflicting slug returns ErrConflict without replacing the current record.

type Config

type Config struct {
	// DB is borrowed from the host; CMS never closes or retunes it.
	DB *sql.DB
	// Dialect is required when DB is provided.
	Dialect Dialect
	// Repository replaces built-in SQL content storage. It requires Authenticate
	// and CSRFKey and currently cannot be combined with media storage.
	Repository Repository
	// BasePath defaults to /admin and must be a clean, non-root absolute path.
	BasePath string
	// Models register the editable schemas. New copies their definitions.
	Models []Model
	// Authenticate reads a trusted host session or request context. A non-nil
	// callback replaces local login; an empty principal ID is unauthenticated.
	Authenticate func(*http.Request) (Principal, error)
	// Authorize replaces the default admin/editor/viewer policy when non-nil.
	Authorize func(context.Context, Principal, Action, Resource) error
	// Media stores image bytes; image fields require this optional adapter.
	Media MediaStorage
	// MediaBasePath defaults to /media/sigocu, outside the admin path.
	MediaBasePath string
	// InsecureCookies allows HTTP development. Leave false behind production HTTPS.
	InsecureCookies bool
	// SessionTTL defaults to 12 hours; allowed values range from one minute to 30 days.
	SessionTTL time.Duration
	// DBTimeout defaults to two seconds, capped by earlier caller deadlines.
	DBTimeout time.Duration
	// CSRFKey is optional with SQL; SQL migrations persist a shared key by default.
	// Custom repositories without SQL must supply exactly 32 random bytes.
	CSRFKey []byte
	// Logger defaults to slog.Default.
	Logger *slog.Logger
}

Config defines immutable models, storage, routing, and authentication. Provide either DB or Repository. With DB and no Authenticate callback, Sigocu's own login is enabled. All callbacks must be safe for concurrent use.

type Dialect

type Dialect string

Dialect selects SQL syntax; the application still chooses and imports its driver.

const (
	// SQLite requires SQLite 3.38 or newer with JSON functions enabled.
	SQLite Dialect = "sqlite"
	// Postgres uses PostgreSQL JSONB storage and numbered SQL parameters.
	Postgres Dialect = "postgres"
)

type Field

type Field struct {
	Name     string
	Label    string
	Help     string
	Type     FieldType
	Required bool
	// Default is validated at construction and applied on creation, not on reads.
	Default any
	// MaxLength counts Unicode characters; encoded records are also limited to 256 KiB.
	MaxLength int
	// Min and Max bound Integer/Decimal values using exact whole-number limits.
	Min     *int64
	Max     *int64
	Choices []string
}

Field declares one editable schema value. The public website decides how to render it; the schema only controls validation and the admin form.

type FieldType

type FieldType string

FieldType selects a field's value validation and generated admin input.

const (
	// Text is a single-line string.
	Text FieldType = "text"
	// Textarea is a multiline plain-text string.
	Textarea FieldType = "textarea"
	// RichText stores Markdown; RenderRichText produces safe formatted HTML.
	RichText FieldType = "richtext"
	// Slug is a lowercase ASCII URL key, optionally indexed with Model.SlugField.
	Slug FieldType = "slug"
	// Integer stores an exact signed 64-bit integer.
	Integer FieldType = "integer"
	// Decimal stores an exact plain decimal string without floating-point conversion.
	Decimal FieldType = "decimal"
	// Boolean stores true or false; required booleans may still be false.
	Boolean FieldType = "boolean"
	// Date stores a YYYY-MM-DD calendar date.
	Date FieldType = "date"
	// DateTime stores an RFC3339 instant normalized to UTC.
	DateTime FieldType = "datetime"
	// URL stores an HTTP(S) URL or a site-relative path.
	URL FieldType = "url"
	// Select stores one of Field.Choices, or an empty optional value.
	Select FieldType = "select"
	// Image stores an ImageValue and requires configured media storage.
	Image FieldType = "image"
)

type ImageValue

type ImageValue struct {
	ID  string `json:"id"`
	Alt string `json:"alt"`
}

ImageValue refers to reusable media and its contextual alternative text.

type ListOptions

type ListOptions struct {
	Limit      int
	BeforeTime int64
	BeforeID   string
	Fields     []string
	AllFields  bool
}

ListOptions is the validated repository-level query. Implementations return at most Limit+1 rows and use BeforeTime/BeforeID as an exclusive cursor boundary. BeforeTime uses UTC Unix microseconds. No field projection means metadata only.

type Media

type Media struct {
	ID          string
	Key         string
	ContentType string
	Size        int64
	Width       int
	Height      int
	CreatedAt   time.Time
}

Media describes a finalized uploaded image. Content stores its ID through an ImageValue; the storage key is owned by the configured MediaStorage adapter.

type MediaStorage

type MediaStorage interface {
	Put(context.Context, string, io.Reader) error
	Open(context.Context, string) (io.ReadCloser, error)
	Delete(context.Context, string) error
}

MediaStorage owns persistent image bytes. Put must either finalize the complete object or clean up its partial output. Keys are generated by CMS, not editors.

type Model

type Model struct {
	Name       string
	Label      string
	TitleField string
	SlugField  string
	Singleton  bool
	// Fields appear in this order in generated admin forms.
	Fields []Field
	// Validate checks the merged values after field validation. Return
	// ValidationErrors for editor-facing field errors. The callback may run concurrently.
	Validate func(Values) error
}

Model registers an editable collection or a singleton settings schema. Names follow [a-z][a-z0-9_]{0,62}; they need not match application table names.

type Page

type Page struct {
	Items      []Record
	NextCursor string
}

Page contains bounded list results and an optional cursor for the next page.

type Principal

type Principal struct {
	ID    string
	Name  string
	Roles []string
}

Principal is a trusted editor identity obtained by an authenticator or host application code. Browser input must not choose its own ID or Roles.

type Query

type Query struct {
	// Limit defaults to 25 and must not exceed 100.
	Limit int
	// After is a Page.NextCursor for the same model and field projection.
	After string
	// Fields names the values to project in SQL; omitted fields are not fetched.
	Fields []string
	// AllFields explicitly loads full documents, including preserved unregistered values.
	AllFields bool
}

Query defaults to metadata-only results. Fields names select document values; AllFields explicitly requests the complete document.

type Record

type Record struct {
	ID        string    `json:"id"`
	Title     string    `json:"title"`
	Slug      string    `json:"slug,omitempty"`
	Values    Values    `json:"values,omitempty"`
	Revision  int64     `json:"revision"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

Record contains content plus identity and concurrency metadata. Values is nil for metadata-only lists. Title is a bounded display copy, not a replacement for the full title value stored in Values.

func (Record) Decode

func (r Record) Decode(dst any) error

Decode copies schema values into dst using standard JSON field names/tags. Pass a non-nil pointer, normally to an existing website view struct. Record metadata is excluded; unrepresented fields are ignored and missing fields leave existing destination values intact. As with encoding/json, a type error may partially update dst: decode into a temporary value when retaining fallbacks. Interface-valued numbers use json.Number to preserve integer precision.

Metadata-only records have nil Values and return ErrInvalid; request Fields or AllFields when listing. Content strings remain untrusted template data. Render Markdown with RenderRichText instead of binding raw strings to template.HTML.

Example
package main

import (
	"fmt"

	cms "github.com/rubberpirate/sigocu"
	_ "modernc.org/sqlite"
)

func main() {
	// The website already renders this view struct in its own template.
	type homeView struct {
		Heading    string `json:"hero_title"`
		ButtonText string `json:"button_text"`
		ButtonURL  string `json:"button_url"`
	}
	// In a website handler, obtain record using admin.Singleton(ctx, "homepage").
	record := cms.Record{Values: cms.Values{
		"hero_title":  "My latest work",
		"button_text": "View projects",
		"button_url":  "/projects",
	}}
	var home homeView
	if err := record.Decode(&home); err != nil {
		panic(err)
	}
	// Pass home into the existing template. Its elements, classes and CSS stay
	// owned by the website; only these values now come from the CMS.
	fmt.Println(home.Heading)
	fmt.Println(home.ButtonText, home.ButtonURL)
}
Output:
My latest work
View projects /projects

type Repository

type Repository interface {
	Get(ctx context.Context, model, id string) (Record, error)
	GetBySlug(ctx context.Context, model, slug string) (Record, error)
	List(ctx context.Context, model string, options ListOptions) ([]Record, error)
	Create(ctx context.Context, model string, record Record) error
	Update(ctx context.Context, model string, record Record, expectedRevision int64) error
	Delete(ctx context.Context, model, id string, expectedRevision int64) error
}

Repository implementations must enforce slug uniqueness and atomic revisions. List returns at most Limit+1 entries ordered by descending creation time and ID. Projection names are validated by CMS before calling List.

type Resource

type Resource struct {
	Model string
	ID    string
}

Resource identifies the collection and optional record for an authorization decision. Media operations use Model "media"; user management may omit Model.

type ValidationErrors

type ValidationErrors map[string]string

ValidationErrors maps field names to editor-facing errors.

func (ValidationErrors) Error

func (e ValidationErrors) Error() string

func (ValidationErrors) Unwrap

func (e ValidationErrors) Unwrap() error

type Values

type Values map[string]any

Values maps schema field names to content. Integers are int64 after SQL reads, decimals are strings, and image references are ImageValue. Removed schema fields are preserved in stored records until an explicit data migration.

Directories

Path Synopsis
examples
basic command
media
local
Package local stores CMS media in a persistent directory using confined paths.
Package local stores CMS media in a persistent directory using confined paths.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL