Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libvips-dev pkg-config curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*

# VERSION is the release string shown in the UI footer (via /version). It is
# passed in at build time because .git is dockerignored, so the build can't run
# `git describe` itself — compute it on the host and forward it:
# docker build --build-arg VERSION="$(git describe --tags --always)" .
# Falls back to "dev" when not supplied.
ARG VERSION=dev

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
Expand All @@ -22,7 +29,10 @@ COPY . .
COPY --from=frontend /app/frontend/dist ./frontend/dist
# The `vips` tag compiles the govips/libvips integration (headers from
# libvips-dev above). Local builds omit it so the toolchain works without libvips.
RUN CGO_ENABLED=1 GOOS=linux go build -tags "vips" -o /app/image-optimizer .
# -X main.version stamps the build version into the binary.
RUN CGO_ENABLED=1 GOOS=linux go build -tags "vips" \
-ldflags "-X main.version=${VERSION}" \
-o /app/image-optimizer .

# ---- Stage 3: minimal runtime ----
FROM debian:bookworm-slim AS runtime
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@
// transform that would otherwise trap its position:fixed overlay.
let cropping = $state(null);

// Build version shown in the footer. Fetched once from /version (injected at
// build time from the git tag); stays '' if the request fails so the footer
// simply omits it. 'dev' is the local-build fallback the server reports.
let appVersion = $state('');
$effect(() => {
let cancelled = false;
fetch('/version')
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (cancelled || !d?.version) return;
// Show tags as "v0.3.1"; leave non-tag builds (e.g. "dev") as-is.
appVersion = /^\d/.test(d.version) ? `v${d.version}` : d.version;
})
.catch(() => {});
return () => {
cancelled = true;
};
});

let status = $derived(progress.status);
let canSubmit = $derived(
status === 'idle' && files.length > 0 && selectedPresets.length > 0,
Expand Down Expand Up @@ -245,6 +264,9 @@
<button type="button" class="hover:text-ctp-blue" onclick={() => (legalOpen = 'privacy')}>
Privacy Policy
</button>
{#if appVersion}
<span class="text-ctp-overlay0/70" title="Build version">{appVersion}</span>
{/if}
</footer>
</main>

Expand Down
10 changes: 6 additions & 4 deletions handlers/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import "github.com/gofiber/fiber/v3"

// RegisterRoutes wires every API route onto the app, sharing one in-memory job
// store across upload, progress, and download. maxFileBytes is the per-file
// upload cap. It returns the store so the caller can start its TTL reaper and
// drain in-flight jobs on shutdown. Call this before the SPA catch-all so the
// API paths are not swallowed by the wildcard route.
func RegisterRoutes(app *fiber.App, maxFileBytes int64) *Store {
// upload cap; version is the build version surfaced at /version (and in the UI
// footer). It returns the store so the caller can start its TTL reaper and drain
// in-flight jobs on shutdown. Call this before the SPA catch-all so the API paths
// are not swallowed by the wildcard route.
func RegisterRoutes(app *fiber.App, maxFileBytes int64, version string) *Store {
store := NewStore()

app.Get("/health", Health)
app.Get("/version", Version(version))
app.Post("/upload", Upload(store, maxFileBytes))
app.Get("/progress/:jobId", Progress(store))
app.Get("/download/:jobId", Download(store))
Expand Down
12 changes: 12 additions & 0 deletions handlers/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package handlers

import "github.com/gofiber/fiber/v3"

// Version returns a handler that responds 200 with the build version as JSON, so
// the frontend can show which release it is running. The value is injected at
// build time (git tag via -ldflags); it falls back to "dev" for local builds.
func Version(version string) fiber.Handler {
return func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"version": version})
}
}
35 changes: 35 additions & 0 deletions handlers/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package handlers

import (
"encoding/json"
"io"
"net/http/httptest"
"testing"

"github.com/gofiber/fiber/v3"
)

func TestVersionHandler(t *testing.T) {
app := fiber.New()
app.Get("/version", Version("v1.2.3"))

resp, err := app.Test(httptest.NewRequest("GET", "/version", nil))
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}

body, _ := io.ReadAll(resp.Body)
var got struct {
Version string `json:"version"`
}
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("unmarshal %q: %v", body, err)
}
if got.Version != "v1.2.3" {
t.Errorf("version = %q, want %q", got.Version, "v1.2.3")
}
}
10 changes: 9 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ var frontendDist embed.FS
// open connections to close, and how long we wait for in-flight jobs to drain.
const shutdownGrace = 30 * time.Second

// version is the build version, surfaced at /version and in the UI footer. It is
// injected at build time from the git tag via:
//
// go build -ldflags "-X main.version=$(git describe --tags)"
//
// (the Dockerfile passes this through a build arg). Local builds keep "dev".
var version = "dev"

func main() {
// -healthcheck turns the binary into its own health probe (used by the
// Docker HEALTHCHECK so the minimal runtime image needs no curl/wget).
Expand Down Expand Up @@ -61,7 +69,7 @@ func main() {
// API routes (health, upload, progress SSE, download). Registered before the
// SPA catch-all so they are not swallowed by the wildcard route. The returned
// store owns job lifecycle; start its TTL reaper and drain it on shutdown.
store := handlers.RegisterRoutes(app, cfg.MaxFileBytes)
store := handlers.RegisterRoutes(app, cfg.MaxFileBytes, version)
store.StartReaper(ctx, cfg.JobTTL)

// Serve the embedded Svelte SPA at the root.
Expand Down
Loading