feat: add packaging, deployment, error pages, and project docs
Phase 7 — Polish: - Error page template with styled 404/403/500 pages - Error rendering helper on Renderer Phase 8 — Packaging & Deployment: - Containerfile: multi-stage build, non-root user, health check, OCI labels with build date and git revision - Makefile: build, test, cross-compile, deb, rpm, container, tarballs, checksums targets - nfpm.yaml: .deb and .rpm package config - systemd service: hardened with NoNewPrivileges, ProtectSystem, ProtectHome, PrivateTmp, RestrictSUIDSGID - Default environment file with commented examples - postinstall/preremove scripts (shellcheck validated) - compose.yaml: example Podman/Docker Compose - Caddyfile.example: subdomain, subpath, and remote proxy configs - CHANGELOG.md for release notes - CLAUDE.md with architecture, conventions, and quick reference Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
845b152f15
commit
1fc42bf1b2
16 changed files with 435 additions and 2 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,6 +1,10 @@
|
|||
# Build artifacts
|
||||
/favoritter
|
||||
/dist/
|
||||
/dist/*.tar.gz
|
||||
/dist/*.deb
|
||||
/dist/*.rpm
|
||||
/dist/checksums.txt
|
||||
/dist/favoritter_*
|
||||
|
||||
# Data (database and uploads)
|
||||
/data/
|
||||
|
|
|
|||
23
CHANGELOG.md
Normal file
23
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to Favoritter are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- User registration with configurable signup modes (open, approval-required, closed)
|
||||
- Favorites with description, optional URL, image upload, tags, and privacy controls
|
||||
- Tag system with autocomplete, browsing, and admin management
|
||||
- User profiles (public or limited visibility) with avatars
|
||||
- Admin panel: user management, tag management, signup requests, site settings
|
||||
- Atom feeds: global, per-user, per-tag
|
||||
- JSON and CSV import/export for data portability
|
||||
- JSON REST API under `/api/v1/`
|
||||
- OpenGraph meta tags for link previews
|
||||
- Argon2id password hashing
|
||||
- CSRF protection, rate limiting, security headers
|
||||
- Proxy-aware deployment (WireGuard/Tailscale support)
|
||||
- Configurable base path for subdomain and subpath deployment
|
||||
- Systemd service file with security hardening
|
||||
- Containerfile for Podman/Docker deployment
|
||||
- Makefile with cross-compilation, .deb/.rpm packaging targets
|
||||
64
CLAUDE.md
Normal file
64
CLAUDE.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Favoritter — Project Guide
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
go build ./cmd/favoritter # Build
|
||||
go test ./... # Test
|
||||
FAVORITTER_DEV_MODE=true \
|
||||
FAVORITTER_ADMIN_USERNAME=admin \
|
||||
FAVORITTER_ADMIN_PASSWORD=dev \
|
||||
go run ./cmd/favoritter # Run (dev mode)
|
||||
make build # Build with version info
|
||||
make test # Run tests
|
||||
make container # Build container image
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Single Go binary serving HTML (server-rendered templates + HTMX) and a JSON API. SQLite for storage, filesystem for uploaded images. All templates and static assets are embedded via `go:embed`.
|
||||
|
||||
### Directory Layout
|
||||
|
||||
- `cmd/favoritter/` — Entry point, wiring, graceful shutdown
|
||||
- `internal/config/` — Environment variable configuration
|
||||
- `internal/database/` — SQLite connection, PRAGMAs, migration runner
|
||||
- `internal/model/` — Domain types (no logic, no DB)
|
||||
- `internal/store/` — Data access layer (one file per entity, plain SQL)
|
||||
- `internal/handler/` — HTTP handlers for web UI
|
||||
- `internal/handler/api/` — JSON REST API handlers
|
||||
- `internal/middleware/` — HTTP middleware (auth, CSRF, rate limit, etc.)
|
||||
- `internal/render/` — Template rendering with layout support
|
||||
- `internal/image/` — Image upload processing (validate, resize, strip EXIF)
|
||||
- `web/templates/` — HTML templates (layouts, pages, partials)
|
||||
- `web/static/` — CSS, JS, vendored Pico CSS + HTMX
|
||||
- `dist/` — Packaging artifacts (systemd, env file, install scripts)
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **Go 1.22+ stdlib router** — no framework, `http.ServeMux` with method routing
|
||||
- **3 external dependencies** — modernc.org/sqlite (pure Go), golang.org/x/crypto (Argon2id), gorilla/feeds
|
||||
- **`SetMaxOpenConns(1)`** — SQLite works best with a single writer; PRAGMAs are set once on the single connection
|
||||
- **Templates embedded in binary** — `//go:embed` for single-binary deployment; dev mode reads from disk for live reload
|
||||
- **Middleware chain order matters** — Recovery → SecurityHeaders → BasePath → RealIP → Logger → SessionLoader → CSRF → MustResetGuard
|
||||
|
||||
### Database
|
||||
|
||||
SQLite with WAL mode. Migrations are embedded SQL files in `internal/database/migrations/`, applied sequentially on startup. Forward-only — no down migrations.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Norwegian Bokmål** for all user-facing text (templates, flash messages, error text)
|
||||
- **SPDX license headers** on all source files
|
||||
- **Argon2id** for password hashing (never bcrypt)
|
||||
- **UUID filenames** for all uploads — never use user-provided filenames
|
||||
- **Errors**: log with `slog.Error` at the handler level, return generic messages to users — never leak internal errors
|
||||
- **Tests**: use real in-memory SQLite (`:memory:`), set fast Argon2 params (`Memory=1024, Time=1`) in tests
|
||||
- **Session cookie name**: use `middleware.SessionCookieName` constant, never hardcode `"session"`
|
||||
- **CSRF**: auto-included in HTMX requests via `htmx:configRequest` JS hook
|
||||
|
||||
## Hosting
|
||||
|
||||
- Hosted on Forgejo at `kode.naiv.no` — use `fj` CLI, not `gh`
|
||||
- Supports deployment as: standalone binary, .deb package, .rpm package, or container
|
||||
- Reverse proxy (Caddy) may be on a different machine — always use `EXTERNAL_URL` and `TRUSTED_PROXIES`
|
||||
32
Caddyfile.example
Normal file
32
Caddyfile.example
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Example Caddy configurations for Favoritter.
|
||||
# Copy the relevant section to your Caddyfile.
|
||||
|
||||
# --- Option 1: Subdomain deployment ---
|
||||
# Set FAVORITTER_EXTERNAL_URL=https://faves.example.com
|
||||
# Set FAVORITTER_TRUSTED_PROXIES to Caddy's IP
|
||||
|
||||
faves.example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
||||
# --- Option 2: Subpath deployment ---
|
||||
# Set FAVORITTER_BASE_PATH=/faves
|
||||
# Set FAVORITTER_EXTERNAL_URL=https://example.com/faves
|
||||
# Set FAVORITTER_TRUSTED_PROXIES to Caddy's IP
|
||||
|
||||
# example.com {
|
||||
# handle_path /faves/* {
|
||||
# reverse_proxy localhost:8080
|
||||
# }
|
||||
# # Redirect /faves to /faves/
|
||||
# redir /faves /faves/ permanent
|
||||
# }
|
||||
|
||||
# --- Option 3: Remote proxy (WireGuard/Tailscale) ---
|
||||
# Caddy runs on a different machine than Favoritter.
|
||||
# Set FAVORITTER_TRUSTED_PROXIES=100.64.0.0/10 (Tailscale)
|
||||
# or the specific WireGuard IP of the Caddy machine.
|
||||
|
||||
# faves.example.com {
|
||||
# reverse_proxy 100.64.1.2:8080
|
||||
# }
|
||||
42
Containerfile
Normal file
42
Containerfile
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# Build: BUILDAH_FORMAT=docker podman build \
|
||||
# --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
# --build-arg GIT_REVISION="$(git describe --always --dirty)" \
|
||||
# -t favoritter .
|
||||
|
||||
FROM docker.io/library/golang:1.23-bookworm AS builder
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_DATE=unknown
|
||||
RUN CGO_ENABLED=0 go build \
|
||||
-ldflags="-s -w -X main.version=${VERSION} -X main.buildDate=${BUILD_DATE}" \
|
||||
-o /favoritter ./cmd/favoritter
|
||||
|
||||
FROM docker.io/library/debian:bookworm-slim
|
||||
ARG BUILD_DATE
|
||||
ARG GIT_REVISION
|
||||
LABEL org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.revision="${GIT_REVISION}" \
|
||||
org.opencontainers.image.source="https://kode.naiv.no/olemd/favoritter" \
|
||||
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
|
||||
org.opencontainers.image.title="Favoritter" \
|
||||
org.opencontainers.image.description="Self-hosted favorites web app"
|
||||
RUN printf 'build_date=%s\ngit_revision=%s\n' "${BUILD_DATE}" "${GIT_REVISION}" > /etc/build-info
|
||||
|
||||
RUN useradd -r -s /usr/sbin/nologin favoritter \
|
||||
&& mkdir -p /data/uploads \
|
||||
&& chown -R favoritter:favoritter /data
|
||||
USER favoritter
|
||||
COPY --from=builder /favoritter /usr/local/bin/favoritter
|
||||
|
||||
ENV FAVORITTER_DB_PATH=/data/favoritter.db \
|
||||
FAVORITTER_UPLOAD_DIR=/data/uploads \
|
||||
FAVORITTER_LISTEN=:8080
|
||||
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD ["/usr/local/bin/favoritter", "-healthcheck"]
|
||||
ENTRYPOINT ["/usr/local/bin/favoritter"]
|
||||
85
Makefile
Normal file
85
Makefile
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Favoritter build system
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.buildDate=$(BUILD_DATE)
|
||||
PLATFORMS := linux/amd64 linux/arm64
|
||||
DIST := dist
|
||||
|
||||
.PHONY: build build-all deb rpm container artifacts checksums clean test lint
|
||||
|
||||
## Build for current platform
|
||||
build:
|
||||
go build -ldflags="$(LDFLAGS)" -o favoritter ./cmd/favoritter
|
||||
|
||||
## Run tests
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
## Cross-compile for all platforms
|
||||
build-all: $(DIST)
|
||||
@for platform in $(PLATFORMS); do \
|
||||
os=$${platform%%/*}; \
|
||||
arch=$${platform##*/}; \
|
||||
echo "Building $$os/$$arch..."; \
|
||||
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \
|
||||
go build -ldflags="$(LDFLAGS)" \
|
||||
-o $(DIST)/favoritter_$(VERSION)_$${os}_$${arch} \
|
||||
./cmd/favoritter; \
|
||||
done
|
||||
|
||||
## Build .deb packages (requires nfpm)
|
||||
deb: build-all
|
||||
@for platform in $(PLATFORMS); do \
|
||||
arch=$${platform##*/}; \
|
||||
echo "Packaging deb for $$arch..."; \
|
||||
ARCH=$$arch VERSION=$(VERSION) nfpm package \
|
||||
--packager deb \
|
||||
--target $(DIST)/favoritter_$(VERSION)_$${arch}.deb; \
|
||||
done
|
||||
|
||||
## Build .rpm packages (requires nfpm)
|
||||
rpm: build-all
|
||||
@for platform in $(PLATFORMS); do \
|
||||
arch=$${platform##*/}; \
|
||||
echo "Packaging rpm for $$arch..."; \
|
||||
ARCH=$$arch VERSION=$(VERSION) nfpm package \
|
||||
--packager rpm \
|
||||
--target $(DIST)/favoritter_$(VERSION)_$${arch}.rpm; \
|
||||
done
|
||||
|
||||
## Build container image
|
||||
container:
|
||||
BUILDAH_FORMAT=docker podman build \
|
||||
--build-arg BUILD_DATE="$(BUILD_DATE)" \
|
||||
--build-arg GIT_REVISION="$(VERSION)" \
|
||||
--build-arg VERSION="$(VERSION)" \
|
||||
-t favoritter:$(VERSION) \
|
||||
-t favoritter:latest .
|
||||
|
||||
## Package binaries into tarballs
|
||||
tarballs: build-all
|
||||
@for platform in $(PLATFORMS); do \
|
||||
os=$${platform%%/*}; \
|
||||
arch=$${platform##*/}; \
|
||||
name=favoritter_$(VERSION)_$${os}_$${arch}; \
|
||||
echo "Creating tarball $$name.tar.gz..."; \
|
||||
tar -czf $(DIST)/$$name.tar.gz \
|
||||
-C $(DIST) $${name} \
|
||||
-C $(CURDIR) README.md LICENSE; \
|
||||
done
|
||||
|
||||
## Generate SHA256 checksums
|
||||
checksums:
|
||||
cd $(DIST) && sha256sum *.tar.gz *.deb *.rpm 2>/dev/null > checksums.txt || true
|
||||
|
||||
## Build all release artifacts
|
||||
artifacts: tarballs deb rpm checksums
|
||||
|
||||
## Clean build artifacts
|
||||
clean:
|
||||
rm -rf $(DIST) favoritter
|
||||
|
||||
$(DIST):
|
||||
mkdir -p $(DIST)
|
||||
24
compose.yaml
Normal file
24
compose.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Example Podman/Docker Compose configuration.
|
||||
# Usage: podman-compose up -d
|
||||
|
||||
services:
|
||||
favoritter:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Containerfile
|
||||
args:
|
||||
BUILD_DATE: "${BUILD_DATE:-unknown}"
|
||||
GIT_REVISION: "${GIT_REVISION:-unknown}"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- favoritter-data:/data
|
||||
environment:
|
||||
FAVORITTER_ADMIN_USERNAME: "${FAVORITTER_ADMIN_USERNAME:-admin}"
|
||||
FAVORITTER_ADMIN_PASSWORD: "${FAVORITTER_ADMIN_PASSWORD:?Set FAVORITTER_ADMIN_PASSWORD}"
|
||||
FAVORITTER_EXTERNAL_URL: "${FAVORITTER_EXTERNAL_URL:-}"
|
||||
FAVORITTER_SITE_NAME: "${FAVORITTER_SITE_NAME:-Favoritter}"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
favoritter-data:
|
||||
16
dist/favoritter.env
vendored
Normal file
16
dist/favoritter.env
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Favoritter configuration
|
||||
# See README.md for all available environment variables.
|
||||
|
||||
FAVORITTER_DB_PATH=/var/lib/favoritter/favoritter.db
|
||||
FAVORITTER_UPLOAD_DIR=/var/lib/favoritter/uploads
|
||||
FAVORITTER_LISTEN=127.0.0.1:8080
|
||||
|
||||
# Uncomment and set on first run to create the admin user:
|
||||
# FAVORITTER_ADMIN_USERNAME=admin
|
||||
# FAVORITTER_ADMIN_PASSWORD=changeme
|
||||
|
||||
# Set this to your public URL for correct feeds, cookies, and OpenGraph:
|
||||
# FAVORITTER_EXTERNAL_URL=https://faves.example.com
|
||||
|
||||
# If your reverse proxy is on another machine (WireGuard/Tailscale):
|
||||
# FAVORITTER_TRUSTED_PROXIES=100.64.0.0/10
|
||||
25
dist/favoritter.service
vendored
Normal file
25
dist/favoritter.service
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[Unit]
|
||||
Description=Favoritter - Self-hosted favorites web app
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=favoritter
|
||||
Group=favoritter
|
||||
EnvironmentFile=/etc/favoritter/favoritter.env
|
||||
ExecStart=/usr/bin/favoritter
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/var/lib/favoritter
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
21
dist/postinstall.sh
vendored
Executable file
21
dist/postinstall.sh
vendored
Executable file
|
|
@ -0,0 +1,21 @@
|
|||
#!/bin/sh
|
||||
# Post-install script for Favoritter .deb/.rpm package.
|
||||
# Creates the system user and sets directory permissions.
|
||||
set -e
|
||||
|
||||
# Create system user if it doesn't exist.
|
||||
if ! getent passwd favoritter >/dev/null 2>&1; then
|
||||
useradd -r -s /usr/sbin/nologin -d /var/lib/favoritter favoritter
|
||||
fi
|
||||
|
||||
# Ensure data directories exist with correct ownership.
|
||||
install -d -o favoritter -g favoritter -m 0750 /var/lib/favoritter
|
||||
install -d -o favoritter -g favoritter -m 0750 /var/lib/favoritter/uploads
|
||||
|
||||
# Reload systemd to pick up the service file.
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
|
||||
echo "Favoritter installed. Configure /etc/favoritter/favoritter.env then run:"
|
||||
echo " sudo systemctl enable --now favoritter"
|
||||
9
dist/preremove.sh
vendored
Executable file
9
dist/preremove.sh
vendored
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/sh
|
||||
# Pre-remove script for Favoritter .deb/.rpm package.
|
||||
# Stops the service before package removal.
|
||||
set -e
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl stop favoritter 2>/dev/null || true
|
||||
systemctl disable favoritter 2>/dev/null || true
|
||||
fi
|
||||
|
|
@ -143,3 +143,8 @@ func (h *Handler) Routes() *http.ServeMux {
|
|||
|
||||
return mux
|
||||
}
|
||||
|
||||
// handleNotFound renders a styled 404 page.
|
||||
func (h *Handler) handleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
h.deps.Renderer.Error(w, r, http.StatusNotFound, "Ikke funnet")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ func RequireAdmin(next http.Handler) http.Handler {
|
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
if user == nil || !user.IsAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte("Forbidden"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
|
|
|
|||
|
|
@ -152,6 +152,18 @@ func (r *Renderer) Page(w http.ResponseWriter, req *http.Request, name string, d
|
|||
}
|
||||
}
|
||||
|
||||
// Error renders an error page with the given HTTP status code.
|
||||
func (r *Renderer) Error(w http.ResponseWriter, req *http.Request, code int, message string) {
|
||||
w.WriteHeader(code)
|
||||
r.Page(w, req, "error", PageData{
|
||||
Title: message,
|
||||
Data: map[string]any{
|
||||
"Code": code,
|
||||
"Message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Partial renders a partial template (for HTMX responses).
|
||||
func (r *Renderer) Partial(w io.Writer, name string, data any) error {
|
||||
key := "partial:" + name
|
||||
|
|
|
|||
52
nfpm.yaml
Normal file
52
nfpm.yaml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# nfpm configuration for building .deb and .rpm packages.
|
||||
# https://nfpm.goreleaser.com/
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# Usage:
|
||||
# ARCH=amd64 VERSION=1.0.0 nfpm package --packager deb --target dist/
|
||||
# ARCH=arm64 VERSION=1.0.0 nfpm package --packager rpm --target dist/
|
||||
|
||||
name: favoritter
|
||||
arch: "${ARCH}"
|
||||
platform: linux
|
||||
version: "${VERSION}"
|
||||
maintainer: "Ole M. <olemd@kode.naiv.no>"
|
||||
description: "Self-hosted favorites web app"
|
||||
vendor: ""
|
||||
homepage: "https://kode.naiv.no/olemd/favoritter"
|
||||
license: AGPL-3.0-or-later
|
||||
|
||||
contents:
|
||||
- src: ./dist/favoritter_${VERSION}_linux_${ARCH}
|
||||
dst: /usr/bin/favoritter
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./dist/favoritter.service
|
||||
dst: /lib/systemd/system/favoritter.service
|
||||
- src: ./dist/favoritter.env
|
||||
dst: /etc/favoritter/favoritter.env
|
||||
type: config|noreplace
|
||||
- dst: /var/lib/favoritter
|
||||
type: dir
|
||||
file_info:
|
||||
mode: 0750
|
||||
owner: favoritter
|
||||
group: favoritter
|
||||
- dst: /var/lib/favoritter/uploads
|
||||
type: dir
|
||||
file_info:
|
||||
mode: 0750
|
||||
owner: favoritter
|
||||
group: favoritter
|
||||
|
||||
scripts:
|
||||
postinstall: ./dist/postinstall.sh
|
||||
preremove: ./dist/preremove.sh
|
||||
|
||||
overrides:
|
||||
deb:
|
||||
depends:
|
||||
- libc6
|
||||
rpm:
|
||||
depends:
|
||||
- glibc
|
||||
18
web/templates/pages/error.html
Normal file
18
web/templates/pages/error.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
<article>
|
||||
<hgroup>
|
||||
<h1>{{.Code}}</h1>
|
||||
<p>{{.Message}}</p>
|
||||
</hgroup>
|
||||
{{if eq .Code 404}}
|
||||
<p>Siden du leter etter finnes ikke. Den kan ha blitt flyttet eller slettet.</p>
|
||||
{{else if eq .Code 403}}
|
||||
<p>Du har ikke tilgang til denne siden.</p>
|
||||
{{else}}
|
||||
<p>Noe gikk galt. Prøv igjen senere.</p>
|
||||
{{end}}
|
||||
<p><a href="{{basePath}}/">Tilbake til forsiden</a></p>
|
||||
</article>
|
||||
{{end}}
|
||||
{{end}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue