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
75 changes: 75 additions & 0 deletions cmd/www/handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (C) 2026 bit8bytes
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main

import (
"fmt"
"net/http"
"net/url"

"github.com/bit8bytes/gearberg/internal/httperr"
"github.com/bit8bytes/gearberg/internal/locale"
"github.com/bit8bytes/gearberg/internal/templates/pages"
"golang.org/x/text/language"
)

func (app *application) getLanding(w http.ResponseWriter, r *http.Request) *httperr.Error {
data := app.html.TemplateData(r)
if locale.TagFrom(r.Context()) == language.German {
return app.html.Render(w, r, http.StatusOK, pages.LandingDE, data)
}
return app.html.Render(w, r, http.StatusOK, pages.Landing, data)
}

func (app *application) postLocale(w http.ResponseWriter, r *http.Request) {
locale := r.FormValue("locale")
http.SetCookie(w, &http.Cookie{
Name: localeCookieName,
Value: locale,
Path: "/",
MaxAge: 365 * 24 * 60 * 60,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
ref := "/"
if u, err := url.Parse(r.Referer()); err == nil && u.Path != "" {
safe := url.URL{Path: u.Path, RawQuery: u.RawQuery}
ref = safe.String()
}
http.Redirect(w, r, ref, http.StatusSeeOther)
}

func (app *application) getImprint(w http.ResponseWriter, r *http.Request) *httperr.Error {
data := app.html.TemplateData(r)
return app.html.Render(w, r, http.StatusOK, pages.Imprint, data)
}

func (app *application) getPrivacy(w http.ResponseWriter, r *http.Request) *httperr.Error {
data := app.html.TemplateData(r)
return app.html.Render(w, r, http.StatusOK, pages.Privacy, data)
}

func getLLMsTxt(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = fmt.Fprint(w, "# Gearberg\n\nOpen-source equipment tracking and rental management. Self-host with Docker. Licensed under AGPL-3.0.\n\n## Links\n\n- [GitHub](https://github.com/bit8bytes/gearberg)\n- [Specification](https://github.com/bit8bytes/gearberg/blob/main/wiki/SPECS.md)\n- [License](https://www.gnu.org/licenses/agpl-3.0.html)\n")
}

func getRobotsTxt(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = fmt.Fprint(w, "User-agent: *\nAllow: /\n")
}
3 changes: 3 additions & 0 deletions cmd/www/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ import (
"syscall"

htmlpkg "github.com/bit8bytes/gearberg/internal/html"
_ "github.com/bit8bytes/gearberg/internal/translations"
)

const localeCookieName = "locale"

type application struct {
logger *slog.Logger
options *options
Expand Down
17 changes: 17 additions & 0 deletions cmd/www/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,35 @@
package main

import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"runtime/debug"
"strings"

"github.com/bit8bytes/gearberg/internal/locale"
"github.com/bit8bytes/gearberg/internal/nonce"
"github.com/bit8bytes/gearberg/internal/trace"
"github.com/bit8bytes/gearberg/pkg/tokens"
"golang.org/x/text/language"
)

func withLocale(next http.Handler) http.Handler {
tags := []language.Tag{language.English, language.German}
matcher := language.NewMatcher(tags)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
loc := "en-US"
if c, err := r.Cookie(localeCookieName); err == nil {
loc = c.Value
}
tag, _ := language.MatchStrings(matcher, loc, r.Header.Get("Accept-Language"))
ctx := context.WithValue(r.Context(), locale.Key{}, tag)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

func withTrace(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := trace.NewContext(r.Context(), tokens.Generate().Hex())
Expand Down
42 changes: 5 additions & 37 deletions cmd/www/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,23 @@
package main

import (
"fmt"
"net/http"
"time"

"github.com/bit8bytes/gearberg/internal/assets"
"github.com/bit8bytes/gearberg/internal/httperr"
"github.com/bit8bytes/gearberg/internal/templates/pages"
)

func (app *application) routes() http.Handler {
mux := http.NewServeMux()

mux.Handle("GET /dist/", assets.ServeStaticFiles())
mux.Handle("GET /favicon.ico", http.RedirectHandler("/dist/images/favicon.ico", http.StatusMovedPermanently))
mux.HandleFunc("GET /robots.txt", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = fmt.Fprint(w, "User-agent: *\nAllow: /\n")
})
mux.HandleFunc("GET /llms.txt", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = fmt.Fprint(w, "# Gearberg\n\nOpen-source equipment tracking and rental management. Self-host with Docker. Licensed under AGPL-3.0.\n\n## Links\n\n- [GitHub](https://github.com/bit8bytes/gearberg)\n- [Specification](https://github.com/bit8bytes/gearberg/blob/main/wiki/SPECS.md)\n- [License](https://www.gnu.org/licenses/agpl-3.0.html)\n")
})
mux.HandleFunc("GET /robots.txt", getRobotsTxt)
mux.HandleFunc("GET /llms.txt", getLLMsTxt)

mux.HandleFunc("/", app.html.Handle(app.getLanding))
mux.HandleFunc("GET /imprint", app.html.Handle(app.getImprint))
mux.HandleFunc("GET /privacy", app.html.Handle(app.getPrivacy))
mux.HandleFunc("POST /locale", app.postLocale)

antiCSRF := http.NewCrossOriginProtection()
logRequest := newRequestLogger(app.logger)
Expand All @@ -54,27 +43,6 @@ func (app *application) routes() http.Handler {
logRequest.handler(
withSecurityHeaders(
withMaxBodySize(
antiCSRF.Handler(mux)))))))
}

func (app *application) getLanding(w http.ResponseWriter, r *http.Request) *httperr.Error {
data := app.html.TemplateData(r)
data.Data = struct {
Year int
}{
Year: time.Now().Year(),
}
return app.html.Render(w, r, http.StatusOK, pages.Landing, data)
}

func (app *application) getImprint(w http.ResponseWriter, r *http.Request) *httperr.Error {
data := app.html.TemplateData(r)
data.Data = struct{ Year int }{Year: time.Now().Year()}
return app.html.Render(w, r, http.StatusOK, pages.Imprint, data)
}

func (app *application) getPrivacy(w http.ResponseWriter, r *http.Request) *httperr.Error {
data := app.html.TemplateData(r)
data.Data = struct{ Year int }{Year: time.Now().Year()}
return app.html.Render(w, r, http.StatusOK, pages.Privacy, data)
antiCSRF.Handler(
withLocale(mux))))))))
}
9 changes: 7 additions & 2 deletions cmd/www/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"log"
"log/slog"
"path/filepath"
"time"

"github.com/bit8bytes/gearberg/internal/templates"
"github.com/bit8bytes/gearberg/internal/templates/pages"
Expand Down Expand Up @@ -48,7 +49,11 @@ func includeSourceFile(_ []string, a slog.Attr) slog.Attr {
}

func templateFuncs() template.FuncMap {
return template.FuncMap{}
return template.FuncMap{
"year": func() int {
return time.Now().Year()
},
}
}

func parseTemplates() (*template.Template, map[string]*template.Template, error) {
Expand All @@ -57,7 +62,7 @@ func parseTemplates() (*template.Template, map[string]*template.Template, error)
return nil, nil, fmt.Errorf("base template: %w", err)
}

allPages := []pages.Page{pages.Landing, pages.Imprint, pages.Privacy, pages.Error, pages.NotFound}
allPages := []pages.Page{pages.Landing, pages.LandingDE, pages.Imprint, pages.Privacy, pages.Error, pages.NotFound}
tmpls := make(map[string]*template.Template, len(allPages))
for _, page := range allPages {
t, err := pageTemplate(templates.EmbedFS, base, page)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
golang.org/x/image v0.43.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.21.0
golang.org/x/text v0.38.0
modernc.org/sqlite v1.50.1
)

Expand Down Expand Up @@ -51,7 +52,6 @@ require (
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
Expand Down
3 changes: 0 additions & 3 deletions internal/assets/dist/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -8070,9 +8070,6 @@
.mask-repeat {
mask-repeat: repeat;
}
.stroke-current {
stroke: currentcolor;
}
.object-contain {
object-fit: contain;
}
Expand Down
4 changes: 4 additions & 0 deletions internal/html/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (

"github.com/bit8bytes/gearberg/internal/flash"
"github.com/bit8bytes/gearberg/internal/httperr"
"github.com/bit8bytes/gearberg/internal/locale"
"github.com/bit8bytes/gearberg/internal/nonce"
"github.com/bit8bytes/gearberg/internal/templates/pages"
"github.com/bit8bytes/gearberg/internal/trace"
Expand All @@ -53,6 +54,8 @@ type TemplateData struct {
OrgID string
// Flash is a one-shot notification popped from the session and rendered as a toast.
Flash *flash.Flash
// Locale is the active locale tag string (e.g. "en-US", "de"), read from the locale cookie.
Locale string
}

// Option configures an HTML renderer.
Expand Down Expand Up @@ -157,5 +160,6 @@ func (rnd *HTML) TemplateData(r *http.Request) *TemplateData {
Revision: rnd.revision,
OrgID: r.PathValue("org_id"),
Flash: rnd.flash.Pop(r.Context()),
Locale: locale.TagFrom(r.Context()).String(),
}
}
28 changes: 28 additions & 0 deletions internal/locale/locale.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Package locale resolves a user's preferred language and vends pre-built
// message.Printers. It is transport-agnostic: cookie middleware, session
// middleware, and tests all call WithTag to store the resolved tag; handlers
// read it back with TagFrom or PrinterFrom.
package locale

import (
"context"

"golang.org/x/text/language"
"golang.org/x/text/message"
)

// Key for storing the resolved language tag.
type Key struct{}

// TagFrom returns the resolved tag stored in ctx, falling back to English.
func TagFrom(ctx context.Context) language.Tag {
if tag, ok := ctx.Value(Key{}).(language.Tag); ok {
return tag
}
return language.English
}

// PrinterFrom returns a Printer for the tag stored in ctx, falling back to English.
func PrinterFrom(ctx context.Context) *message.Printer {
return message.NewPrinter(TagFrom(ctx))
}
Loading
Loading