feat: embed templates and static assets with go:embed for distroless containers
Replace all runtime template.ParseFiles() calls with template.ParseFS() using an embedded filesystem, and serve static assets from an embedded FS via http.FileServerFS(). This eliminates the need for COPY steps in the Dockerfile and ensures the binary works with readOnlyRootFilesystem: true. - Add internal/templates/embed.go exposing templates.FS - Add static/embed.go exposing static.FS - Update all handlers to use template.ParseFS(templates.FS, ...) - Update static file server to use http.FileServerFS(static.FS) - Remove COPY static/ and COPY internal/templates/ from Dockerfile - Remove TestMain working directory hack (no longer needed) Closes leeworks-agents/gitea-mobile#231 Closes leeworks-agents/gitea-mobile#220 Closes leeworks-agents/gitea-mobile#221 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -7,10 +7,10 @@ COPY . .
|
|||||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /gitea-mobile ./cmd/server
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /gitea-mobile ./cmd/server
|
||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
|
# Templates and static assets are embedded in the binary via go:embed,
|
||||||
|
# so no COPY steps are needed for them.
|
||||||
FROM gcr.io/distroless/static:nonroot
|
FROM gcr.io/distroless/static:nonroot
|
||||||
COPY --from=builder /gitea-mobile /gitea-mobile
|
COPY --from=builder /gitea-mobile /gitea-mobile
|
||||||
COPY static/ /static/
|
|
||||||
COPY internal/templates/ /templates/
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
USER nonroot:nonroot
|
USER nonroot:nonroot
|
||||||
ENTRYPOINT ["/gitea-mobile"]
|
ENTRYPOINT ["/gitea-mobile"]
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config"
|
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config"
|
||||||
giteaclient "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/gitea"
|
giteaclient "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/gitea"
|
||||||
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
|
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
|
||||||
|
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/templates"
|
||||||
|
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler holds shared dependencies for all HTTP handlers.
|
// Handler holds shared dependencies for all HTTP handlers.
|
||||||
@@ -64,8 +66,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
}
|
}
|
||||||
mux.HandleFunc("/settings", settingsHandler.ServeHTTP)
|
mux.HandleFunc("/settings", settingsHandler.ServeHTTP)
|
||||||
|
|
||||||
// Static files.
|
// Static files — served from embedded filesystem.
|
||||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(static.FS)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// isHTMX returns true if the request is an HTMX partial request.
|
// isHTMX returns true if the request is an HTMX partial request.
|
||||||
@@ -235,7 +237,7 @@ func (h *Handler) ErrorInternal(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// renderError renders the error template with the given data and status code.
|
// renderError renders the error template with the given data and status code.
|
||||||
func (h *Handler) renderError(w http.ResponseWriter, r *http.Request, data errorData) {
|
func (h *Handler) renderError(w http.ResponseWriter, r *http.Request, data errorData) {
|
||||||
tmpl, err := template.ParseFiles("internal/templates/error.html")
|
tmpl, err := template.ParseFS(templates.FS, "error.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse error template", "error", err)
|
slog.Error("failed to parse error template", "error", err)
|
||||||
http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code)
|
http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code)
|
||||||
@@ -298,7 +300,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl, err := template.ParseFiles("internal/templates/dashboard.html")
|
tmpl, err := template.ParseFS(templates.FS, "dashboard.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse dashboard template", "error", err)
|
slog.Error("failed to parse dashboard template", "error", err)
|
||||||
http.Error(w, "template error", http.StatusInternalServerError)
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
@@ -390,7 +392,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// For HTMX infinite-scroll requests (page > 1), return only the card fragment.
|
// For HTMX infinite-scroll requests (page > 1), return only the card fragment.
|
||||||
if isHTMX(r) && page > 1 {
|
if isHTMX(r) && page > 1 {
|
||||||
tmpl, err := template.ParseFiles("internal/templates/issues.html")
|
tmpl, err := template.ParseFS(templates.FS, "issues.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse issues template", "error", err)
|
slog.Error("failed to parse issues template", "error", err)
|
||||||
http.Error(w, "template error", http.StatusInternalServerError)
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
@@ -407,7 +409,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl, err := template.ParseFiles("internal/templates/issues.html")
|
tmpl, err := template.ParseFS(templates.FS, "issues.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse issues template", "error", err)
|
slog.Error("failed to parse issues template", "error", err)
|
||||||
http.Error(w, "template error", http.StatusInternalServerError)
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
@@ -502,7 +504,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// For HTMX infinite-scroll requests (page > 1), return only the card fragment.
|
// For HTMX infinite-scroll requests (page > 1), return only the card fragment.
|
||||||
if isHTMX(r) && page > 1 {
|
if isHTMX(r) && page > 1 {
|
||||||
tmpl, err := template.ParseFiles("internal/templates/pulls.html")
|
tmpl, err := template.ParseFS(templates.FS, "pulls.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse pulls template", "error", err)
|
slog.Error("failed to parse pulls template", "error", err)
|
||||||
http.Error(w, "template error", http.StatusInternalServerError)
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
@@ -519,7 +521,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl, err := template.ParseFiles("internal/templates/pulls.html")
|
tmpl, err := template.ParseFS(templates.FS, "pulls.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse pulls template", "error", err)
|
slog.Error("failed to parse pulls template", "error", err)
|
||||||
http.Error(w, "template error", http.StatusInternalServerError)
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
@@ -587,7 +589,7 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build the content HTML using the template.
|
// Build the content HTML using the template.
|
||||||
tmpl, err := template.ParseFiles("internal/templates/issue_detail.html")
|
tmpl, err := template.ParseFS(templates.FS, "issue_detail.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse issue_detail template", "error", err)
|
slog.Error("failed to parse issue_detail template", "error", err)
|
||||||
http.Error(w, "template error", http.StatusInternalServerError)
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
@@ -660,7 +662,7 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build the content HTML using the template.
|
// Build the content HTML using the template.
|
||||||
tmpl, err := template.ParseFiles("internal/templates/pull_detail.html")
|
tmpl, err := template.ParseFS(templates.FS, "pull_detail.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse pull_detail template", "error", err)
|
slog.Error("failed to parse pull_detail template", "error", err)
|
||||||
http.Error(w, "template error", http.StatusInternalServerError)
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
@@ -701,7 +703,7 @@ func (h *Handler) NewIssue(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl, err := template.ParseFiles("internal/templates/create_issue.html")
|
tmpl, err := template.ParseFS(templates.FS, "create_issue.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse create_issue template", "error", err)
|
slog.Error("failed to parse create_issue template", "error", err)
|
||||||
http.Error(w, "template error", http.StatusInternalServerError)
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -18,26 +15,8 @@ import (
|
|||||||
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
|
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestMain changes the working directory to the project root so that
|
// Note: TestMain is no longer needed because templates and static assets
|
||||||
// template files can be found by handlers that use relative paths.
|
// are embedded at compile time via go:embed. Tests work from any directory.
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
// Walk up from this source file to find the project root (where go.mod lives).
|
|
||||||
_, filename, _, _ := runtime.Caller(0)
|
|
||||||
dir := filepath.Dir(filename)
|
|
||||||
for {
|
|
||||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
parent := filepath.Dir(dir)
|
|
||||||
if parent == dir {
|
|
||||||
// Reached filesystem root without finding go.mod; run tests from cwd.
|
|
||||||
break
|
|
||||||
}
|
|
||||||
dir = parent
|
|
||||||
}
|
|
||||||
_ = os.Chdir(dir)
|
|
||||||
os.Exit(m.Run())
|
|
||||||
}
|
|
||||||
|
|
||||||
// mockGiteaAPI starts an httptest server that simulates the Gitea API
|
// mockGiteaAPI starts an httptest server that simulates the Gitea API
|
||||||
// endpoints needed by the handlers. Returns the server and a cleanup func.
|
// endpoints needed by the handlers. Returns the server and a cleanup func.
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import (
|
|||||||
|
|
||||||
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/auth"
|
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/auth"
|
||||||
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
|
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
|
||||||
|
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
const settingsTemplatePath = "internal/templates/settings.html"
|
|
||||||
|
|
||||||
// SettingsHandler handles GET and POST requests for the settings page.
|
// SettingsHandler handles GET and POST requests for the settings page.
|
||||||
type SettingsHandler struct {
|
type SettingsHandler struct {
|
||||||
SessionSecret string
|
SessionSecret string
|
||||||
@@ -101,7 +100,7 @@ func (h *SettingsHandler) renderWithMessage(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *SettingsHandler) renderSettings(w http.ResponseWriter, data settingsData) {
|
func (h *SettingsHandler) renderSettings(w http.ResponseWriter, data settingsData) {
|
||||||
tmpl, err := template.ParseFiles(settingsTemplatePath)
|
tmpl, err := template.ParseFS(templates.FS, "settings.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse settings template", "error", err)
|
slog.Error("failed to parse settings template", "error", err)
|
||||||
http.Error(w, "template error", http.StatusInternalServerError)
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Package templates provides embedded HTML template files.
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// FS contains all HTML template files embedded at compile time.
|
||||||
|
//
|
||||||
|
//go:embed *.html
|
||||||
|
var FS embed.FS
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Package static provides embedded static asset files (CSS, JS, icons, etc.).
|
||||||
|
package static
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// FS contains all static asset files embedded at compile time.
|
||||||
|
// Files are served via http.FileServerFS in the handlers package.
|
||||||
|
//
|
||||||
|
//go:embed *.css *.js *.json *.png
|
||||||
|
var FS embed.FS
|
||||||
Reference in New Issue
Block a user