200 lines
6.5 KiB
Go
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
|
|
}
|