Skip to content

Repository files navigation

goblog

Build and Test codecov License

A self-hosted blogging platform built with Go. Running at https://www.jasonernst.com

Features

Content

  • Markdown posts with code syntax highlighting and table support
  • Draft / publish workflow
  • Post revision history with rollback
  • Tags with tag cloud
  • Configurable post types (blog posts, notes, etc.)
  • Full-text search
  • File uploads (images, PDFs, etc.)
  • Internal and external backlink tracking
  • Comments with markdown support, spam honeypot, and rate limiting; by default commenters must be logged in (GitHub or email code)
  • RSS-ready sitemap generation

Pages

  • Configurable dynamic pages (writing, research, archives, tags, about, custom)
  • Research page fed by Google Scholar or the Semantic Scholar API (scholar plugin), with on-disk caching and throttle resilience
  • Archives sorted by year and month

Theming

  • WordPress-style theme system (themes/{name}/)
  • Switch themes from admin settings without restart (hot-reload)
  • Two built-in themes: default (monospace, gray) and minimal (sans-serif, blue accent)
  • Theme-specific CSS served at /theme/
  • Custom header/footer code injection via settings (for analytics, etc.)

Admin

  • GitHub OAuth login
  • Install wizard for first-time setup
  • Admin dashboard with recent comments, and a paginated comments page for moderation
  • Configurable settings (site title, subtitle, social URLs, favicon, etc.)
  • Post type management
  • Page management with hero images/videos

Plugins

  • Plugin system for injecting template data / HTML, scheduled jobs, settings, and whole pages
  • Built-in plugins: analytics, socialicons, scholar (research page; Google Scholar is blocked from most cloud IPs, so set its source setting to semantic_scholar when hosting in a datacenter), directory (the plugin directory that runs goblog.live/plugins; off by default)
  • Dynamic plugins: drop a .go file in plugins/dynamic/ — no rebuild (see Plugins)

Infrastructure

  • SQLite (file-based, zero config), MySQL, or PostgreSQL
  • Docker support with tagged releases on Docker Hub
  • Configurable trusted proxies for reverse proxy deployments (TRUSTED_PROXIES env var)
  • GitHub Actions CI/CD

Quick Start

Local

go build
./goblog

Visit http://localhost:7000 and follow the install wizard.

Docker

docker run -p 7000:7000 compscidr/goblog:latest

Database

SQLite is the default and needs no setup. To use MySQL or PostgreSQL instead, pick it in the install wizard or set the variables in .env (see template.env):

database=postgres
POSTGRES_HOST=localhost
POSTGRES_PORT=5432          # default
POSTGRES_USER=goblog
POSTGRES_PASSWORD=...
POSTGRES_DATABASE=goblog
POSTGRES_SSLMODE=disable    # default; or require / verify-ca / verify-full

The schema is created and migrated automatically on startup for all three. There is no built-in tool for moving an existing site between databases.

Behind a Reverse Proxy

Set TRUSTED_PROXIES so X-Forwarded-For headers are trusted for client IP resolution:

TRUSTED_PROXIES=172.16.0.0/12 ./goblog

Pinning the Admin Account

On a fresh install the first GitHub account to complete login becomes the admin. If you pre-populate .env (e.g. from configuration management) and skip the wizard, anyone could win that race. Pin it to your own account by adding either or both of these to .env:

admin_login=your-github-username      # case-insensitive
admin_github_id=12345                 # numeric id: https://api.github.com/users/your-github-username

Other accounts can still log in as regular users but are never promoted. Leave both unset to keep the first-to-login behaviour.

Managing Admins

The pin above only decides who becomes the first admin. After that, admins are managed from the Users page in the admin area (/admin/users), which lists everyone who has logged in. An existing admin can promote any GitHub user to admin or demote another admin; the last remaining admin can't be demoted, so the site never ends up with none. Email-login users can't be made admin (see #565).

To hand the site over to a different GitHub account: log in with the new account once so it appears in the list, promote it from your current admin account, then log in as the new account and demote the old one.

Email Login (one-time codes)

Visitors without a GitHub account can log in with an emailed 6-digit code. Add SMTP details to .env:

smtp_host=smtp.example.com
smtp_port=587                         # 465 for implicit TLS; anything else uses STARTTLS when offered
smtp_user=postmaster@example.com      # omit for an unauthenticated relay
smtp_password=...
smtp_from=blog@example.com

When smtp_host and smtp_from are both set the login page offers "sign in with email"; otherwise it shows GitHub only. Codes expire after 10 minutes, allow 5 wrong attempts, and can be re-requested once a minute. Email users are regular users — the admin account is still GitHub-only (see above).

SMTP settings are read once at startup, so restart goblog after changing any smtp_* value in .env for the change to take effect. Go's SMTP client only sends smtp_user/smtp_password over an encrypted connection (STARTTLS, or implicit TLS on port 465) unless the host is localhost, so if you need an unencrypted remote relay, use it without credentials.

Comments and Login

Comments require a logged-in user by default: the comment form is replaced by a "Log in to leave a comment" link, and a comment is attributed to the account that posted it (the email is always the account's; the name defaults to the GitHub name or the email's local part but can be edited per comment). Since email login is the way most readers will get an account, configure SMTP as above. If you would rather allow anonymous comments — for example on a site with GitHub login only — untick comments_require_login on the admin settings page.

Theming

Themes live in themes/{name}/ with this structure:

themes/
  default/
    templates/    # HTML templates
    static/       # CSS and assets (served at /theme/)
  minimal/
    templates/
    static/

To create a custom theme:

  1. Copy themes/default/ to themes/my-theme/
  2. Customize templates and CSS
  3. Set the theme setting to my-theme in admin settings

Plugins

A plugin implements the plugin.Plugin interface (plugin/plugin.go). Embed plugin.BasePlugin to get no-op defaults and implement only the hooks you need:

Hook What it does
Name(), DisplayName(), Version() Identity. Name() is the unique key used to store the plugin's settings.
Settings() Declares settings. They appear under Admin → Settings grouped by plugin, are stored in plugin_settings, and reach every hook as strings via ctx.Settings. The admin UI renders Type: "textarea" as a textarea and everything else as a single-line text input (there is no file or checkbox widget for plugin settings yet, so store booleans as "true"/"false"). Declare an enabled setting to get the on/off toggle — the registry calls every plugin regardless, so honour ctx.Settings["enabled"] yourself.
TemplateHead(ctx) / TemplateFooter(ctx) Return raw HTML injected into <head> / before </body> on every rendered page. Escape anything that came from settings or the request.
TemplateData(ctx) Returns data made available to templates as .plugins.<name>.
ScheduledJobs() Periodic background jobs (Name, Interval, Run(db, settings)), started at boot.
Pages() / RenderPage(ctx, pageType) Own a page type: it gets a slug, an optional nav entry, and you choose the template and data when it is visited. The plugin also owns everything under its slug: ctx.SubPath is "" for /research, "2024" for /research/2024. Return a template name to render it inside the theme, or write the response yourself (e.g. ctx.GinContext.JSON(...)) and return ""; returning "" without writing anything gives a 404. plugins/scholar is the simplest example, plugins/directory uses sub-paths.
OnInit(db) Runs once at startup, after settings are seeded.

ctx is a *plugin.HookContext carrying the Gin context, the DB, the plugin's own settings, the template being rendered, and the existing template data. plugins/socialicons is the smallest complete example.

Compiled-in plugins

Live in plugins/<name>/ as a normal Go package, and are registered in main():

registry.Register(myplugin.New())

They have full access to gin, gorm, and any module dependency, and are part of the release binary. Use this for anything that ships with goblog.

Dynamic plugins

Loaded at startup from plugins/dynamic/*.go by the embedded Yaegi Go interpreter — no rebuild, so they work with the Docker image. Enable with:

ENABLE_DYNAMIC_PLUGINS=true ./goblog

A dynamic plugin is a single package main file defining func NewPlugin() plugin.Plugin. Start from the shipped example:

cp plugins/dynamic/hello.go.example plugins/dynamic/hello.go
ENABLE_DYNAMIC_PLUGINS=true ./goblog     # every page now ends with a greeting

then edit the message under Admin → Settings → Hello (example).

Limits of the interpreted environment:

  • Available imports are the Go standard library and goblog/plugin (Plugin, BasePlugin, HookContext, SettingDefinition, ScheduledJob, PageDefinition). gin and gorm are not available, so the hooks that name their types — TemplateData, ScheduledJobs, OnInit, RenderPage — can't be implemented dynamically; write a compiled-in plugin for those.
  • A file that fails to load is logged and skipped; the rest still load.
  • Dynamic plugins run as ordinary Go code inside the goblog process with stdlib access. Only load files you control; plugins/dynamic/ should be writable by the operator alone.

With Docker, bind-mount the directory and set the flag:

docker run -p 7000:7000 -e ENABLE_DYNAMIC_PLUGINS=true \
  -v $PWD/plugins/dynamic:/go/src/github.com/compscidr/goblog/plugins/dynamic \
  compscidr/goblog:latest

Checking a plugin file

goblog validate-plugin <file.go> loads a single file through the same interpreter and prints its identity as JSON (exit 1 with the load error on stderr if it fails):

./goblog validate-plugin plugins/dynamic/hello.go.example
# {"name":"hello","display_name":"Hello (example)","version":"1.0.0"}

With the Docker image (its entrypoint is a shell command, so override it):

docker run --rm --network none -v "$PWD:/p" --entrypoint /go/src/github.com/compscidr/goblog/goblog \
  compscidr/goblog:latest validate-plugin /p/plugin.go

This is what the plugin directory registry runs on every submission.

Plugin directory

goblog.live/plugins lists published dynamic plugins; https://goblog.live/plugins/index.json is the same list as JSON (name, version, author, license, download_url, sha256, min_goblog_version). Plugins are individual GitHub repositories with releases; the curated list and the build that produces the index live in goblogplatform/plugins, which also documents how to submit one.

The pages are rendered by the built-in directory plugin, which any goblog can turn on under Admin → Settings → Plugin Directory (enabled = true). It fetches index_url every refresh_minutes, keeps the last good copy if the registry is unreachable, and serves /plugins, /plugins/<name> and /plugins/index.json. Only point index_url at a registry you trust: its README, changelog and release-note HTML is shown as-is.

Testing

go test ./...

Architecture

  • Gin for HTTP routing and middleware
  • GORM for database ORM (SQLite, MySQL support)
  • Showdown.js + DOMPurify for client-side markdown rendering
  • Bootstrap 5 for UI framework
  • Server-side rendered templates with JSON REST API at /api/v1/

About

Simple blogging platform built with golang. Built on top of gin and gorm. Auth with github. REST / CRUD API backend and templated frontend

Topics

Resources

Stars

50 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages