Files
hotdog-cms/internal/publish/container.go
T

200 lines
6.5 KiB
Go

package publish
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/build"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/check"
)
// The image serves files and nothing else: an unprivileged web server, the
// site, and a config that answers the way hotdog-cms lays a site out. Folders get
// their index.html, a missing path gets 404.html with a 404, fingerprinted
// assets are cached for a year, and pages are revalidated every time.
const nginxContainerfile = `# Written by hotdog-cms. The site, served by unprivileged nginx on port 8080.
FROM docker.io/nginxinc/nginx-unprivileged:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY site/ /usr/share/nginx/html/
EXPOSE 8080
`
const nginxConf = `server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
# Redirects (/about -> /about/) keep the visitor's own host and port. An
# absolute redirect would name the container's port 8080, which the
# outside world can't reach.
absolute_redirect off;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location / {
try_files $uri $uri/ =404;
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
}
# hotdog-cms puts a content hash in every asset's name, so a name never
# changes content and can be cached for good.
location ~ "\.[0-9a-f]{10}\.[A-Za-z0-9]+$" {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Calendar feeds: nginx doesn't know .ics, and calendar apps want
# text/calendar.
location ~ "\.ics$" {
default_type "text/calendar; charset=utf-8";
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
}
# hotdog-cms's own bookkeeping (.hotdog-cms-build, -redirects, -pages):
# not for visitors.
location ~ "^/\.hotdog-cms-" { return 404; }
{{REDIRECTS}} error_page 404 /404.html;
}
`
const caddyContainerfile = `# Written by hotdog-cms. The site, served by Caddy on port 8080.
FROM docker.io/library/caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY site/ /srv/
EXPOSE 8080
`
const caddyFile = `:8080 {
root * /srv
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
X-Frame-Options SAMEORIGIN
-Server
}
@hashed path_regexp \.[0-9a-f]{10}\.[A-Za-z0-9]+$
header @hashed Cache-Control "public, immutable, max-age=31536000"
header ?Cache-Control "no-cache"
@ics path *.ics
header @ics Content-Type "text/calendar; charset=utf-8"
respond /.hotdog-cms-* 404
{{REDIRECTS}} file_server
handle_errors {
@404 expression {err.status_code} == 404
rewrite @404 /404.html
file_server
}
}
`
// container writes a build context and, unless only the context was asked
// for, builds the image and pushes it if told to.
func container(t Target, out string, log io.Writer) error {
ctx := t.Context
if ctx == "" {
dir, err := os.MkdirTemp("", "hotdog-cms-image-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
ctx = dir
} else if err := os.MkdirAll(ctx, 0o755); err != nil {
return err
}
csp := ""
if t.CSP {
csp = check.Policy(out)
}
if err := writeContext(ctx, out, t.Server, csp); err != nil {
return err
}
if t.Image == "" {
fmt.Fprintf(log, "build context written to %s\n", ctx)
return nil
}
tool := t.Tool
if tool == "" {
for _, c := range []string{"podman", "docker"} {
if _, err := exec.LookPath(c); err == nil {
tool = c
break
}
}
}
if tool == "" {
return fmt.Errorf("container target needs docker or podman installed, or set context to only write the build context")
}
run := func(args ...string) error {
cmd := exec.Command(tool, args...)
cmd.Stdout, cmd.Stderr = log, log
return cmd.Run()
}
if err := run("build", "-t", t.Image, ctx); err != nil {
return fmt.Errorf("%s build: %w", tool, err)
}
fmt.Fprintf(log, "built image %s\n", t.Image)
if t.Push {
if err := run("push", t.Image); err != nil {
return fmt.Errorf("%s push: %w", tool, err)
}
fmt.Fprintf(log, "pushed %s\n", t.Image)
}
return nil
}
func writeContext(ctx, out, server, csp string) error {
site := filepath.Join(ctx, "site")
if err := os.RemoveAll(site); err != nil {
return err
}
if err := build.CopyTree(out, site); err != nil {
return err
}
// The site's redirects become real 301s; the pages the build wrote at
// the old addresses are only the fallback for hosts that can't do this.
var nginxRules, caddyRules strings.Builder
if raw, err := os.ReadFile(filepath.Join(out, build.RedirectsFile)); err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
from, to, ok := strings.Cut(line, " ")
if !ok || strings.ContainsAny(from+to, "\";{}\n\r\t ") {
return fmt.Errorf("redirect %q can't be written into a server config", line)
}
fmt.Fprintf(&nginxRules, " location = %s { return 301 %s; }\n", from, to)
fmt.Fprintf(&caddyRules, "\tredir %s %s 301\n", from, to)
}
}
nconf, cconf := nginxConf, caddyFile
if csp != "" {
if strings.ContainsAny(csp, "\"\n\r") {
return fmt.Errorf("content security policy can't be written into a server config")
}
h := `add_header Content-Security-Policy "` + csp + `" always;`
nconf = strings.ReplaceAll(nconf, `add_header X-Content-Type-Options "nosniff" always;`, `add_header X-Content-Type-Options "nosniff" always;`+"\n "+h)
cconf = strings.Replace(cconf, "\t\tX-Content-Type-Options nosniff", "\t\tX-Content-Type-Options nosniff\n\t\tContent-Security-Policy \""+csp+"\"", 1)
}
files := map[string]string{"Containerfile": nginxContainerfile, "nginx.conf": strings.Replace(nconf, "{{REDIRECTS}}", nginxRules.String(), 1)}
if server == "caddy" {
files = map[string]string{"Containerfile": caddyContainerfile, "Caddyfile": strings.Replace(cconf, "{{REDIRECTS}}", caddyRules.String(), 1)}
}
// Dockerfile as well, for tools that look only for that name.
files["Dockerfile"] = files["Containerfile"]
for name, body := range files {
if err := os.WriteFile(filepath.Join(ctx, name), []byte(body), 0o644); err != nil {
return err
}
}
return nil
}