mirror of
https://github.com/stashapp/stash.git
synced 2025-12-10 08:55:17 -06:00
Login page internationalisation (#5765)
* Load locale strings in login page * Generate and use login locale strings * Add makefile target * Update workflow * Update build dockerfiles * Add missing default string
This commit is contained in:
parent
c8d74f0bcf
commit
d9b4e62420
3
.gitignore
vendored
3
.gitignore
vendored
@ -21,6 +21,9 @@ vendor
|
|||||||
# GraphQL generated output
|
# GraphQL generated output
|
||||||
internal/api/generated_*.go
|
internal/api/generated_*.go
|
||||||
|
|
||||||
|
# Generated locale files
|
||||||
|
ui/login/locales/*
|
||||||
|
|
||||||
####
|
####
|
||||||
# Visual Studio
|
# Visual Studio
|
||||||
####
|
####
|
||||||
|
|||||||
9
Makefile
9
Makefile
@ -281,6 +281,10 @@ generate-ui:
|
|||||||
generate-backend: touch-ui
|
generate-backend: touch-ui
|
||||||
go generate ./cmd/stash
|
go generate ./cmd/stash
|
||||||
|
|
||||||
|
.PHONY: generate-login-locale
|
||||||
|
generate-login-locale:
|
||||||
|
go generate ./ui
|
||||||
|
|
||||||
.PHONY: generate-dataloaders
|
.PHONY: generate-dataloaders
|
||||||
generate-dataloaders:
|
generate-dataloaders:
|
||||||
go generate ./internal/api/loaders
|
go generate ./internal/api/loaders
|
||||||
@ -351,7 +355,10 @@ ifdef STASH_SOURCEMAPS
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: ui
|
.PHONY: ui
|
||||||
ui: ui-env
|
ui: ui-only generate-login-locale
|
||||||
|
|
||||||
|
.PHONY: ui-only
|
||||||
|
ui-only: ui-env
|
||||||
cd ui/v2.5 && yarn build
|
cd ui/v2.5 && yarn build
|
||||||
|
|
||||||
.PHONY: zip-ui
|
.PHONY: zip-ui
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# This dockerfile should be built with `make docker-build` from the stash root.
|
# This dockerfile should be built with `make docker-build` from the stash root.
|
||||||
|
|
||||||
# Build Frontend
|
# Build Frontend
|
||||||
FROM node:alpine as frontend
|
FROM node:20-alpine AS frontend
|
||||||
RUN apk add --no-cache make git
|
RUN apk add --no-cache make git
|
||||||
## cache node_modules separately
|
## cache node_modules separately
|
||||||
COPY ./ui/v2.5/package.json ./ui/v2.5/yarn.lock /stash/ui/v2.5/
|
COPY ./ui/v2.5/package.json ./ui/v2.5/yarn.lock /stash/ui/v2.5/
|
||||||
@ -13,19 +13,22 @@ RUN make pre-ui
|
|||||||
RUN make generate-ui
|
RUN make generate-ui
|
||||||
ARG GITHASH
|
ARG GITHASH
|
||||||
ARG STASH_VERSION
|
ARG STASH_VERSION
|
||||||
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui
|
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
|
||||||
|
|
||||||
# Build Backend
|
# Build Backend
|
||||||
FROM golang:1.22-alpine as backend
|
FROM golang:1.22.8-alpine AS backend
|
||||||
RUN apk add --no-cache make alpine-sdk
|
RUN apk add --no-cache make alpine-sdk
|
||||||
WORKDIR /stash
|
WORKDIR /stash
|
||||||
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
|
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
|
||||||
|
COPY ./graphql /stash/graphql/
|
||||||
COPY ./scripts /stash/scripts/
|
COPY ./scripts /stash/scripts/
|
||||||
COPY ./pkg /stash/pkg/
|
COPY ./pkg /stash/pkg/
|
||||||
COPY ./cmd /stash/cmd
|
COPY ./cmd /stash/cmd/
|
||||||
COPY ./internal /stash/internal
|
COPY ./internal /stash/internal/
|
||||||
|
# needed for generate-login-locale
|
||||||
|
COPY ./ui /stash/ui/
|
||||||
|
RUN make generate-backend generate-login-locale
|
||||||
COPY --from=frontend /stash /stash/
|
COPY --from=frontend /stash /stash/
|
||||||
RUN make generate-backend
|
|
||||||
ARG GITHASH
|
ARG GITHASH
|
||||||
ARG STASH_VERSION
|
ARG STASH_VERSION
|
||||||
RUN make flags-release flags-pie stash
|
RUN make flags-release flags-pie stash
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# This dockerfile should be built with `make docker-cuda-build` from the stash root.
|
# This dockerfile should be built with `make docker-cuda-build` from the stash root.
|
||||||
|
|
||||||
# Build Frontend
|
# Build Frontend
|
||||||
FROM node:alpine as frontend
|
FROM node:20-alpine AS frontend
|
||||||
RUN apk add --no-cache make git
|
RUN apk add --no-cache make git
|
||||||
## cache node_modules separately
|
## cache node_modules separately
|
||||||
COPY ./ui/v2.5/package.json ./ui/v2.5/yarn.lock /stash/ui/v2.5/
|
COPY ./ui/v2.5/package.json ./ui/v2.5/yarn.lock /stash/ui/v2.5/
|
||||||
@ -13,19 +13,22 @@ RUN make pre-ui
|
|||||||
RUN make generate-ui
|
RUN make generate-ui
|
||||||
ARG GITHASH
|
ARG GITHASH
|
||||||
ARG STASH_VERSION
|
ARG STASH_VERSION
|
||||||
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui
|
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
|
||||||
|
|
||||||
# Build Backend
|
# Build Backend
|
||||||
FROM golang:1.22-bullseye as backend
|
FROM golang:1.22.8-bullseye AS backend
|
||||||
RUN apt update && apt install -y build-essential golang
|
RUN apt update && apt install -y build-essential golang
|
||||||
WORKDIR /stash
|
WORKDIR /stash
|
||||||
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
|
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
|
||||||
|
COPY ./graphql /stash/graphql/
|
||||||
COPY ./scripts /stash/scripts/
|
COPY ./scripts /stash/scripts/
|
||||||
COPY ./pkg /stash/pkg/
|
COPY ./pkg /stash/pkg/
|
||||||
COPY ./cmd /stash/cmd
|
COPY ./cmd /stash/cmd
|
||||||
COPY ./internal /stash/internal
|
COPY ./internal /stash/internal
|
||||||
|
# needed for generate-login-locale
|
||||||
|
COPY ./ui /stash/ui/
|
||||||
|
RUN make generate-backend generate-login-locale
|
||||||
COPY --from=frontend /stash /stash/
|
COPY --from=frontend /stash /stash/
|
||||||
RUN make generate-backend
|
|
||||||
ARG GITHASH
|
ARG GITHASH
|
||||||
ARG STASH_VERSION
|
ARG STASH_VERSION
|
||||||
RUN make flags-release flags-pie stash
|
RUN make flags-release flags-pie stash
|
||||||
|
|||||||
@ -4,7 +4,7 @@ This dockerfile is used to build a stash docker container using the current sour
|
|||||||
|
|
||||||
# Building the docker container
|
# Building the docker container
|
||||||
|
|
||||||
From the top-level directory (should contain `main.go` file):
|
From the top-level directory (should contain `tools.go` file):
|
||||||
|
|
||||||
```
|
```
|
||||||
make docker-build
|
make docker-build
|
||||||
|
|||||||
@ -41,10 +41,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
loginEndpoint = "/login"
|
loginEndpoint = "/login"
|
||||||
logoutEndpoint = "/logout"
|
loginLocaleEndpoint = loginEndpoint + "/locale"
|
||||||
gqlEndpoint = "/graphql"
|
logoutEndpoint = "/logout"
|
||||||
playgroundEndpoint = "/playground"
|
gqlEndpoint = "/graphql"
|
||||||
|
playgroundEndpoint = "/playground"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@ -228,6 +229,7 @@ func Initialize() (*Server, error) {
|
|||||||
r.Get(loginEndpoint, handleLogin())
|
r.Get(loginEndpoint, handleLogin())
|
||||||
r.Post(loginEndpoint, handleLoginPost())
|
r.Post(loginEndpoint, handleLoginPost())
|
||||||
r.Get(logoutEndpoint, handleLogout())
|
r.Get(logoutEndpoint, handleLogout())
|
||||||
|
r.Get(loginLocaleEndpoint, handleLoginLocale(cfg))
|
||||||
r.HandleFunc(loginEndpoint+"/*", func(w http.ResponseWriter, r *http.Request) {
|
r.HandleFunc(loginEndpoint+"/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, loginEndpoint)
|
r.URL.Path = strings.TrimPrefix(r.URL.Path, loginEndpoint)
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
|||||||
@ -17,7 +17,11 @@ import (
|
|||||||
"github.com/stashapp/stash/ui"
|
"github.com/stashapp/stash/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
const returnURLParam = "returnURL"
|
const (
|
||||||
|
returnURLParam = "returnURL"
|
||||||
|
|
||||||
|
defaultLocale = "en-GB"
|
||||||
|
)
|
||||||
|
|
||||||
func getLoginPage() []byte {
|
func getLoginPage() []byte {
|
||||||
data, err := fs.ReadFile(ui.LoginUIBox, "login.html")
|
data, err := fs.ReadFile(ui.LoginUIBox, "login.html")
|
||||||
@ -58,6 +62,47 @@ func serveLoginPage(w http.ResponseWriter, r *http.Request, returnURL string, lo
|
|||||||
utils.ServeStaticContent(w, r, buffer.Bytes())
|
utils.ServeStaticContent(w, r, buffer.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleLoginLocale(cfg *config.Config) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// get the locale from the config
|
||||||
|
lang := cfg.GetLanguage()
|
||||||
|
if lang == "" {
|
||||||
|
lang = defaultLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := getLoginLocale(lang)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("Failed to load login locale file for language %s: %v", lang, err)
|
||||||
|
// try again with the default language
|
||||||
|
if lang != defaultLocale {
|
||||||
|
data, err = getLoginLocale(defaultLocale)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to load login locale file for default language %s: %v", defaultLocale, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there's still an error, response with an internal server error
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to load login locale file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write a script to set the locale string map as a global variable
|
||||||
|
localeScript := fmt.Sprintf("var localeStrings = %s;", data)
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
_, _ = w.Write([]byte(localeScript))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLoginLocale(lang string) ([]byte, error) {
|
||||||
|
data, err := fs.ReadFile(ui.LoginUIBox, "locales/"+lang+".json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
func handleLogin() http.HandlerFunc {
|
func handleLogin() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
returnURL := r.URL.Query().Get(returnURLParam)
|
returnURL := r.URL.Query().Get(returnURLParam)
|
||||||
|
|||||||
90
scripts/generateLoginLocales.go
Normal file
90
scripts/generateLoginLocales.go
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
//go:build ignore
|
||||||
|
// +build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/fsutil"
|
||||||
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
verbose := len(os.Args) > 1 && os.Args[1] == "-v"
|
||||||
|
|
||||||
|
fmt.Printf("Generating login locales\n")
|
||||||
|
|
||||||
|
// read all json files in the locales directory
|
||||||
|
// and extract only the login part
|
||||||
|
|
||||||
|
// assume running from ui directory
|
||||||
|
dirFS := os.DirFS(filepath.Join("v2.5", "src", "locales"))
|
||||||
|
|
||||||
|
// ensure the login/locales directory exists
|
||||||
|
if err := fsutil.EnsureDir(filepath.Join("login", "locales")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.WalkDir(dirFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if filepath.Ext(path) != ".json" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract the login part
|
||||||
|
// from the json file
|
||||||
|
src, err := dirFS.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer src.Close()
|
||||||
|
data, err := io.ReadAll(src)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := make(utils.NestedMap)
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
l, found := m.Get("login")
|
||||||
|
if !found {
|
||||||
|
// nothing to do
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// create new json file
|
||||||
|
// with only the login part
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Writing %s\n", d.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(filepath.Join("login", "locales", d.Name()))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer f.Close()
|
||||||
|
e := json.NewEncoder(f)
|
||||||
|
if err := e.Encode(l); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -9,6 +9,18 @@
|
|||||||
<link rel="shortcut icon" href="data:,">
|
<link rel="shortcut icon" href="data:,">
|
||||||
<link rel="stylesheet" href="login/login.css">
|
<link rel="stylesheet" href="login/login.css">
|
||||||
<link rel="stylesheet" href="css">
|
<link rel="stylesheet" href="css">
|
||||||
|
|
||||||
|
<!-- load locale -->
|
||||||
|
<script>
|
||||||
|
var localeStrings = {
|
||||||
|
username: "Username",
|
||||||
|
password: "Password",
|
||||||
|
login: "Login",
|
||||||
|
invalid_credentials: "Invalid credentials",
|
||||||
|
internal_error: "Unexpected internal error. See logs for more details"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="login/locale"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -25,28 +37,27 @@
|
|||||||
if (xhr.status == 200) {
|
if (xhr.status == 200) {
|
||||||
window.location.replace(returnURL);
|
window.location.replace(returnURL);
|
||||||
} else {
|
} else {
|
||||||
document.getElementsByClassName("login-error")[0].innerHTML = xhr.responseText;
|
document.getElementsByClassName("login-error")[0].innerHTML = localeStrings.invalid_credentials;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
xhr.onerror = function() {
|
xhr.onerror = function() {
|
||||||
document.getElementsByClassName("login-error")[0].innerHTML = "An error occurred while trying to login.";
|
document.getElementsByClassName("login-error")[0].innerHTML = localeStrings.internal_error;
|
||||||
};
|
};
|
||||||
xhr.send("username=" + username + "&password=" + password + "&returnURL=" + returnURL);
|
xhr.send("username=" + username + "&password=" + password + "&returnURL=" + returnURL);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<body class="login">
|
<body class="login">
|
||||||
|
|
||||||
<div class="dialog">
|
<div class="dialog">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<form action="login" method="POST" onsubmit="event.preventDefault(); login();">
|
<form action="login" method="POST" onsubmit="event.preventDefault(); login();">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username"><h6>Username</h6></label>
|
<label for="username"><h6 id="username-heading">Username</h6></label>
|
||||||
<input class="text-input form-control" id="username" name="username" type="text" placeholder="Username" />
|
<input class="text-input form-control" id="username" name="username" type="text" placeholder="Username" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password"><h6>Password</h6></label>
|
<label for="password"><h6 id="password-heading">Password</h6></label>
|
||||||
<input class="text-input form-control" id="password" name="password" type="password" placeholder="Password" />
|
<input class="text-input form-control" id="password" name="password" type="password" placeholder="Password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="login-error">
|
<div class="login-error">
|
||||||
@ -56,11 +67,19 @@
|
|||||||
<input type="hidden" id="returnURL" name="returnURL" value="{{.URL}}" />
|
<input type="hidden" id="returnURL" name="returnURL" value="{{.URL}}" />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input class="btn btn-primary" type="submit" value="Login">
|
<input id="login-button" class="btn btn-primary" type="submit" value="Login">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById("username-heading").innerText = localeStrings.username;
|
||||||
|
document.getElementById("password-heading").innerText = localeStrings.password;
|
||||||
|
document.getElementById("username").placeholder = localeStrings.username;
|
||||||
|
document.getElementById("password").placeholder = localeStrings.password;
|
||||||
|
document.getElementById("login-button").value = localeStrings.login;
|
||||||
|
</script>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
1
ui/ui.go
1
ui/ui.go
@ -1,3 +1,4 @@
|
|||||||
|
//go:generate go run -tags=dev ../scripts/generateLoginLocales.go
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -1142,6 +1142,13 @@
|
|||||||
"generic": "Loading…",
|
"generic": "Loading…",
|
||||||
"plugins": "Loading plugins…"
|
"plugins": "Loading plugins…"
|
||||||
},
|
},
|
||||||
|
"login": {
|
||||||
|
"login": "Login",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"invalid_credentials": "Invalid username or password",
|
||||||
|
"internal_error": "Unexpected internal error. See logs for more details"
|
||||||
|
},
|
||||||
"marker_count": "Marker Count",
|
"marker_count": "Marker Count",
|
||||||
"markers": "Markers",
|
"markers": "Markers",
|
||||||
"measurements": "Measurements",
|
"measurements": "Measurements",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user