From 6304b32d9f1f9ebbd759ab7ef0ab27e4c92e4e0d Mon Sep 17 00:00:00 2001
From: eretlukas <33234640+eretl@users.noreply.github.com>
Date: Fri, 24 Jul 2026 21:55:38 +0200
Subject: [PATCH 1/2] Add translations support for the application
---
internal/languages/Languages.go | 279 +++++++++++++
internal/languages/translations/cs.json | 371 ++++++++++++++++++
internal/languages/translations/en.json | 371 ++++++++++++++++++
internal/webserver/Webserver.go | 153 ++++++--
internal/webserver/Webserver_test.go | 1 +
.../webserver/authentication/oauth/Oauth.go | 13 +-
.../webserver/errorHandling/ErrorHandling.go | 98 +++--
internal/webserver/web/static/js/admin_api.js | 58 +--
.../web/static/js/admin_ui_allPages.js | 8 +-
.../webserver/web/static/js/admin_ui_api.js | 38 +-
.../web/static/js/admin_ui_filerequest.js | 26 +-
.../webserver/web/static/js/admin_ui_logs.js | 32 +-
.../web/static/js/admin_ui_upload.js | 80 ++--
.../webserver/web/static/js/admin_ui_users.js | 50 +--
.../webserver/web/static/js/all_public.js | 46 ++-
.../webserver/web/static/js/end2end_admin.js | 14 +-
.../web/static/js/min/admin.min.ccb62a8d5b.js | 10 +-
.../web/static/js/min/all_public.min.js | 2 +-
.../js/min/end2end_admin.min.ccb62a8d5b.js | 2 +-
.../web/static/js/min/public_upload.min.js | 2 +-
.../webserver/web/static/js/public_upload.js | 46 +--
.../web/templates/expired_file_svg.tmpl | 2 +-
.../webserver/web/templates/html_admin.tmpl | 178 +++++----
.../webserver/web/templates/html_api.tmpl | 92 ++---
.../web/templates/html_changepw.tmpl | 20 +-
.../web/templates/html_download.tmpl | 48 +--
.../web/templates/html_download_password.tmpl | 14 +-
.../webserver/web/templates/html_end2end.tmpl | 50 +--
.../webserver/web/templates/html_error.tmpl | 47 ++-
.../webserver/web/templates/html_footer.tmpl | 16 +-
.../web/templates/html_forgotpw.tmpl | 8 +-
.../webserver/web/templates/html_header.tmpl | 48 ++-
.../webserver/web/templates/html_login.tmpl | 22 +-
.../webserver/web/templates/html_logs.tmpl | 84 ++--
.../web/templates/html_public_upload.tmpl | 28 +-
.../web/templates/html_redirect_filename.tmpl | 12 +-
.../web/templates/html_uploadrequest.tmpl | 153 ++++----
.../webserver/web/templates/html_users.tmpl | 179 +++++----
38 files changed, 1966 insertions(+), 735 deletions(-)
create mode 100644 internal/languages/Languages.go
create mode 100644 internal/languages/translations/cs.json
create mode 100644 internal/languages/translations/en.json
diff --git a/internal/languages/Languages.go b/internal/languages/Languages.go
new file mode 100644
index 00000000..a1e3d4ad
--- /dev/null
+++ b/internal/languages/Languages.go
@@ -0,0 +1,279 @@
+// Package languages provides the translations for the Gokapi web interface.
+//
+// All translations are stored as flat JSON files in the translations folder and
+// are embedded into the binary. English is always used as a fallback: if a
+// translation is missing a key, the English string is displayed instead.
+//
+// Strings may contain the placeholders {0}, {1}, ... which are replaced by the
+// arguments passed to Tf / Hf. A few strings intentionally contain HTML markup
+// (e.g. links or spans that JavaScript writes into) - those are rendered with
+// H / Hf and must keep their tags and ids when being translated.
+package languages
+
+import (
+ "embed"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// translationFiles contains all embedded translation files
+//
+//go:embed translations/*.json
+var translationFiles embed.FS
+
+// CookieName is the name of the cookie that stores the language selected by the user
+const CookieName = "gokapi_language"
+
+// DefaultCode is the language that is used if no other language matches the request
+const DefaultCode = "en"
+
+// keyLanguageName is the key that contains the native name of the language
+const keyLanguageName = "language_name"
+
+// Language contains the metadata of an available translation
+type Language struct {
+ // Code is the ISO 639-1 code of the language, e.g. "de"
+ Code string
+ // Name is the name of the language in that language itself, e.g. "Deutsch"
+ Name string
+}
+
+// Translator contains all strings of a single language and is passed to the templates
+type Translator struct {
+ // Code is the ISO 639-1 code of the language, e.g. "de"
+ Code string
+ // Name is the name of the language in that language itself, e.g. "Deutsch"
+ Name string
+
+ entries map[string]string
+ jsonData template.JS
+}
+
+var (
+ // translators contains all loaded languages, indexed by their language code
+ translators = make(map[string]Translator)
+ // available contains all loaded languages, sorted by their code
+ available []Language
+ // fallback contains the English strings, which are used if a key is missing
+ fallback map[string]string
+)
+
+func init() {
+ files, err := translationFiles.ReadDir("translations")
+ if err != nil {
+ panic(err)
+ }
+ rawEntries := make(map[string]map[string]string)
+ for _, file := range files {
+ if file.IsDir() || !strings.HasSuffix(file.Name(), ".json") {
+ continue
+ }
+ content, err := translationFiles.ReadFile("translations/" + file.Name())
+ if err != nil {
+ panic(err)
+ }
+ entries := make(map[string]string)
+ err = json.Unmarshal(content, &entries)
+ if err != nil {
+ panic("invalid translation file " + file.Name() + ": " + err.Error())
+ }
+ rawEntries[strings.TrimSuffix(file.Name(), ".json")] = entries
+ }
+
+ var ok bool
+ fallback, ok = rawEntries[DefaultCode]
+ if !ok {
+ panic("translation file for default language " + DefaultCode + " is missing")
+ }
+
+ for code, entries := range rawEntries {
+ // Merging with the English strings, so that an incomplete translation does
+ // not result in empty strings being sent to the browser
+ merged := make(map[string]string, len(fallback))
+ for key, value := range fallback {
+ merged[key] = value
+ }
+ for key, value := range entries {
+ if value != "" {
+ merged[key] = value
+ }
+ }
+ encoded, err := json.Marshal(merged)
+ if err != nil {
+ panic(err)
+ }
+ translators[code] = Translator{
+ Code: code,
+ Name: merged[keyLanguageName],
+ entries: merged,
+ jsonData: template.JS(encoded),
+ }
+ available = append(available, Language{Code: code, Name: merged[keyLanguageName]})
+ }
+ sort.Slice(available, func(i, j int) bool {
+ return available[i].Name < available[j].Name
+ })
+}
+
+// Get returns the Translator for the given language code. If the language is
+// unknown, the default language is returned instead.
+func Get(code string) Translator {
+ translator, ok := translators[normaliseCode(code)]
+ if !ok {
+ return Default()
+ }
+ return translator
+}
+
+// Default returns the Translator for the default language
+func Default() Translator {
+ return translators[DefaultCode]
+}
+
+// IsAvailable returns true if a translation exists for the given language code
+func IsAvailable(code string) bool {
+ _, ok := translators[normaliseCode(code)]
+ return ok
+}
+
+// GetAvailable returns all languages that Gokapi has been translated to, sorted by name
+func GetAvailable() []Language {
+ result := make([]Language, len(available))
+ copy(result, available)
+ return result
+}
+
+// FromRequest returns the Translator that fits the request best. The language
+// selected by the user (stored in a cookie) has priority, otherwise the
+// Accept-Language header is used. If neither matches, the default language is
+// returned.
+func FromRequest(r *http.Request) Translator {
+ if r == nil {
+ return Default()
+ }
+ cookie, err := r.Cookie(CookieName)
+ if err == nil {
+ if translator, ok := translators[normaliseCode(cookie.Value)]; ok {
+ return translator
+ }
+ }
+ if translator, ok := fromAcceptLanguage(r.Header.Get("Accept-Language")); ok {
+ return translator
+ }
+ return Default()
+}
+
+// fromAcceptLanguage parses an Accept-Language header and returns the best
+// matching Translator. The second return value is false if no language matched.
+func fromAcceptLanguage(header string) (Translator, bool) {
+ type weightedLanguage struct {
+ code string
+ weight float64
+ }
+ var candidates []weightedLanguage
+ for _, entry := range strings.Split(header, ",") {
+ entry = strings.TrimSpace(entry)
+ if entry == "" {
+ continue
+ }
+ code := entry
+ weight := 1.0
+ if index := strings.Index(entry, ";"); index != -1 {
+ code = strings.TrimSpace(entry[:index])
+ for _, parameter := range strings.Split(entry[index+1:], ";") {
+ parameter = strings.TrimSpace(parameter)
+ if !strings.HasPrefix(parameter, "q=") {
+ continue
+ }
+ parsed, err := strconv.ParseFloat(strings.TrimPrefix(parameter, "q="), 64)
+ if err == nil {
+ weight = parsed
+ }
+ }
+ }
+ if code == "" || weight <= 0 {
+ continue
+ }
+ candidates = append(candidates, weightedLanguage{code: code, weight: weight})
+ }
+ sort.SliceStable(candidates, func(i, j int) bool {
+ return candidates[i].weight > candidates[j].weight
+ })
+ for _, candidate := range candidates {
+ if translator, ok := translators[normaliseCode(candidate.code)]; ok {
+ return translator, true
+ }
+ }
+ return Default(), false
+}
+
+// normaliseCode converts a language tag like "de-DE" to the language code "de"
+func normaliseCode(code string) string {
+ code = strings.ToLower(strings.TrimSpace(code))
+ if index := strings.IndexAny(code, "-_"); index != -1 {
+ code = code[:index]
+ }
+ return code
+}
+
+// T returns the translated string for the given key. If the key does not exist
+// in this language, the English string is returned. If it does not exist at all,
+// the key itself is returned, so that a missing translation is easy to spot.
+func (t Translator) T(key string) string {
+ if value, ok := t.entries[key]; ok {
+ return value
+ }
+ if value, ok := fallback[key]; ok {
+ return value
+ }
+ return key
+}
+
+// Tf returns the translated string for the given key, with the placeholders
+// {0}, {1}, ... replaced by the passed arguments
+func (t Translator) Tf(key string, args ...any) string {
+ return replacePlaceholders(t.T(key), args)
+}
+
+// H returns the translated string for the given key as HTML. This must only be
+// used for strings that intentionally contain markup, as the content is not
+// escaped.
+func (t Translator) H(key string) template.HTML {
+ return template.HTML(t.T(key))
+}
+
+// Hf returns the translated string for the given key as HTML, with the
+// placeholders {0}, {1}, ... replaced by the passed arguments. This must only be
+// used for strings that intentionally contain markup, as the content is not
+// escaped.
+func (t Translator) Hf(key string, args ...any) template.HTML {
+ return template.HTML(t.Tf(key, args...))
+}
+
+// Js returns all strings of this language as a JSON object, so that they can be
+// embedded into a script tag and used by the JavaScript function t()
+func (t Translator) Js() template.JS {
+ if t.jsonData == "" {
+ return Default().jsonData
+ }
+ return t.jsonData
+}
+
+// Available returns all languages that Gokapi has been translated to, so that a
+// template can render the language selector
+func (t Translator) Available() []Language {
+ return GetAvailable()
+}
+
+// replacePlaceholders replaces {0}, {1}, ... with the passed arguments
+func replacePlaceholders(input string, args []any) string {
+ for i, arg := range args {
+ input = strings.ReplaceAll(input, "{"+strconv.Itoa(i)+"}", fmt.Sprint(arg))
+ }
+ return input
+}
diff --git a/internal/languages/translations/cs.json b/internal/languages/translations/cs.json
new file mode 100644
index 00000000..5855e038
--- /dev/null
+++ b/internal/languages/translations/cs.json
@@ -0,0 +1,371 @@
+{
+ "language_name": "Čeština",
+
+ "generic_unlimited": "Neomezeně",
+ "generic_never": "Nikdy",
+ "generic_none": "Žádné",
+ "generic_online": "Online",
+ "generic_expired": "Vypršelo",
+ "generic_language": "Jazyk",
+ "generic_loading": "Načítání...",
+ "generic_total": "Celkem",
+
+ "btn_close": "Zavřít",
+ "btn_cancel": "Zrušit",
+ "btn_save": "Uložit",
+ "btn_save_changes": "Uložit změny",
+ "btn_confirm": "Potvrdit",
+ "btn_ok": "OK",
+ "btn_continue": "Pokračovat",
+ "btn_delete": "Smazat",
+ "btn_edit": "Upravit",
+ "btn_download": "Stáhnout",
+ "btn_share": "Sdílet",
+ "btn_copy_url": "Kopírovat URL",
+ "btn_copy_hotlink": "Kopírovat přímý odkaz",
+ "btn_hotlink": "Přímý odkaz",
+ "btn_hotlink_unavailable": "Přímý odkaz není k dispozici",
+ "btn_qr_code": "QR kód",
+ "btn_open_qr_code": "Zobrazit QR kód",
+ "btn_email": "E-mail",
+ "btn_share_email": "Sdílet e-mailem",
+ "btn_url": "URL",
+ "btn_remove": "Odebrat",
+
+ "nav_upload": "Nahrát",
+ "nav_file_requests": "Žádosti o soubory",
+ "nav_users": "Uživatelé",
+ "nav_api": "API",
+ "nav_status": "Stav",
+ "nav_logout": "Odhlásit se",
+ "nav_admin_title": "Správa",
+
+ "footer_powered_by": "Běží na",
+
+ "login_title": "Přihlášení",
+ "login_username": "Uživatelské jméno",
+ "login_password": "Heslo",
+ "login_expired": "Přihlašovací stránka byla otevřená příliš dlouho a její platnost vypršela. Zkuste to prosím znovu.",
+ "login_incorrect": "Nesprávné uživatelské jméno nebo heslo!",
+ "login_forgot_password": "Zapomenuté heslo",
+
+ "forgotpw_title": "Zapomenuté heslo",
+ "forgotpw_text": "Pokud jste zapomněli své uživatelské heslo, požádejte prosím správce o jeho obnovení.
Heslo správce obnovíte tak, že restartujete server s parametrem --reconfigure a změníte je v sekci ověřování.",
+
+ "changepw_title": "Změna hesla",
+ "changepw_text": "Byla vyžádána změna hesla. Zadejte prosím nové heslo.",
+ "changepw_new_password": "Nové heslo",
+ "changepw_confirm_password": "Potvrdit nové heslo",
+ "changepw_not_matching": "Hesla se neshodují",
+ "changepw_error_form": "Byla odeslána neplatná data formuláře",
+ "changepw_error_csrf": "Formulář nebyl odeslán celý",
+ "changepw_error_too_short": "Heslo je příliš krátké",
+ "changepw_error_same": "Nové heslo se musí lišit od původního",
+
+ "upload_title": "Nahrát",
+ "upload_please_wait": "Čekejte prosím",
+ "upload_loading_e2e": "Načítá se koncové šifrování...",
+ "upload_dragdrop": "Přetáhněte soubory sem",
+ "upload_dragdrop_sub": "nebo je vložte či vyberte kliknutím.",
+ "upload_dragdrop_sub_e2e": "nebo je vložte či vyberte kliknutím pro nahrání s koncovým šifrováním",
+ "upload_option_download_limit": "Limit stažení",
+ "upload_option_download_limit_enable": "Zapnout limit stažení",
+ "upload_option_downloads": "Stažení",
+ "upload_option_expiry": "Platnost",
+ "upload_option_time_limit_enable": "Zapnout časový limit",
+ "upload_option_days": "Dní",
+ "upload_option_password": "Heslo",
+ "upload_option_password_enable": "Zapnout ochranu heslem",
+ "upload_option_no_password": "Bez hesla",
+ "duration_years": "r",
+ "duration_days": "d",
+ "duration_hours": "h",
+ "duration_minutes": "m",
+ "duration_seconds": "s",
+ "dropzone_file_too_big": "Soubor je příliš velký ({{filesize}} MiB). Maximální velikost: {{maxFilesize}} MiB.",
+ "dropzone_no_dragdrop": "Váš prohlížeč nepodporuje nahrávání souborů přetažením.",
+
+ "filetable_filename": "Název souboru",
+ "filetable_size": "Velikost",
+ "filetable_downloads_remaining": "Zbývá stažení",
+ "filetable_stored_until": "Uloženo do",
+ "filetable_downloads": "Stažení",
+ "filetable_id": "ID",
+ "filetable_actions": "Akce",
+ "filetable_password_protected": "Chráněno heslem",
+ "filetable_files_stored": "Uložených souborů: {0}",
+ "filetable_empty": "Zatím nejsou uloženy žádné soubory",
+ "datatable_search": "Hledat:",
+ "datatable_search_placeholder": "Název souboru nebo ID",
+ "datatable_zero_records": "Nebyly nalezeny žádné odpovídající soubory",
+ "datatable_info_filtered": "(filtrováno z celkem _MAX_ souborů)",
+ "datatable_sort_ascending": ": aktivujte pro seřazení sloupce vzestupně",
+ "datatable_sort_descending": ": aktivujte pro seřazení sloupce sestupně",
+
+ "toast_url_copied": "URL zkopírována do schránky",
+ "toast_api_key_copied": "API klíč zkopírován do schránky",
+ "toast_password_copied": "Heslo zkopírováno do schránky",
+ "toast_invalid": "Neplatné oznámení",
+ "toast_file_deleted": "Soubor smazán:",
+ "toast_restore": "Obnovit",
+ "toast_deprecation": "Upozornění! Tento server používá zastaralou funkci. Podrobnosti najdete v systémových protokolech.",
+ "toast_pw_change_required_set": "Vyžádání změny hesla bylo úspěšně nastaveno",
+ "toast_also_granting_replace": "Zároveň se uděluje oprávnění nahrazovat vlastní soubory",
+ "toast_also_revoking_replace": "Zároveň se odebírá oprávnění nahrazovat soubory jiných uživatelů",
+
+ "editmodal_title": "Název souboru",
+ "editmodal_limit_downloads": "Omezit stažení",
+ "editmodal_limit_downloads_aria": "Omezit stažení",
+ "editmodal_downloads_remaining": "Zbývající stažení",
+ "editmodal_expiry": "Platnost",
+ "editmodal_expire_files": "Nechat soubory vypršet",
+ "editmodal_password": "Heslo",
+ "editmodal_require_password": "Vyžadovat heslo",
+ "editmodal_replace_content": "Nahradit obsah",
+ "editmodal_replace_file_content": "Nahradit obsah souboru",
+ "editmodal_replace_aria": "Nahradit obsah souboru",
+ "editmodal_password_unchanged": "(beze změny)",
+ "editmodal_replace_unavailable": "Nedostupné",
+ "editmodal_replace_e2e_notice": "Nahrazení obsahu není u koncově šifrovaných souborů k dispozici",
+
+ "api_title": "API klíče",
+ "api_hint": "Další informace o API najdete v dokumentaci API. Kliknutím na název API klíče jej přejmenujete. Oprávnění změníte kliknutím na ně.",
+ "api_col_name": "Název",
+ "api_col_key": "API klíč",
+ "api_col_last_used": "Naposledy použit",
+ "api_col_permissions": "Oprávnění",
+ "api_col_user": "Uživatel",
+ "api_col_actions": "Akce",
+ "api_unnamed_key": "Nepojmenovaný klíč",
+ "api_copy_key": "Kopírovat API klíč",
+
+ "perm_legend_title": "Legenda oprávnění",
+ "perm_legend_show": "Zobrazit legendu oprávnění",
+
+ "apiperm_view": "Zobrazit nahrané soubory",
+ "apiperm_upload": "Nahrávat soubory",
+ "apiperm_edit": "Upravovat nahrané soubory",
+ "apiperm_delete": "Mazat nahrané soubory",
+ "apiperm_replace": "Nahrazovat nahrané soubory",
+ "apiperm_download": "Stahovat soubory",
+ "apiperm_file_requests": "Spravovat žádosti o soubory",
+ "apiperm_users": "Spravovat uživatele",
+ "apiperm_logs": "Spravovat systémové protokoly",
+ "apiperm_api": "Spravovat API klíče",
+
+ "users_title": "Uživatelé",
+ "users_col_user": "Uživatel",
+ "users_col_group": "Skupina",
+ "users_col_last_online": "Naposledy online",
+ "users_col_uploads": "Nahrané soubory",
+ "users_col_permissions": "Oprávnění",
+ "users_col_actions": "Akce",
+ "userlevel_super_admin": "Superadministrátor",
+ "userlevel_admin": "Administrátor",
+ "userlevel_user": "Uživatel",
+ "userlevel_invalid": "Neplatná",
+ "users_reset_password": "Obnovit heslo",
+ "users_promote": "Povýšit uživatele",
+ "users_demote": "Degradovat uživatele",
+ "users_delete_title": "Smazat uživatele",
+ "users_delete_confirm": "Opravdu chcete smazat uživatele ? Tuto akci nelze vrátit zpět.",
+ "users_delete_files": "Trvale smazat všechny soubory nahrané tímto uživatelem.",
+ "users_delete_button": "Smazat uživatele",
+ "users_new_title": "Vytvořit nového uživatele",
+ "users_new_username": "Uživatelské jméno",
+ "users_new_username_placeholder": "Zadejte uživatelské jméno",
+ "users_new_button": "Přidat uživatele",
+ "users_resetpw_title": "Obnovit heslo",
+ "users_resetpw_intro": "Zvolte způsob obnovení hesla pro uživatele :",
+ "users_resetpw_force": "Vynutit nastavení nového hesla při příštím přihlášení",
+ "users_resetpw_random": "Vygenerovat nové náhodné heslo (uživatel je bude muset při příštím přihlášení změnit)",
+ "users_resetpw_new": "Nové heslo:",
+ "users_resetpw_copy": "Kopírovat heslo",
+
+ "userperm_file_requests": "Vytvářet žádosti o soubory",
+ "userperm_replace_own": "Nahrazovat vlastní soubory",
+ "userperm_list_other": "Zobrazit soubory jiných uživatelů",
+ "userperm_edit_other": "Upravovat soubory jiných uživatelů",
+ "userperm_delete_other": "Mazat soubory jiných uživatelů",
+ "userperm_replace_other": "Nahrazovat soubory jiných uživatelů",
+ "userperm_logs": "Spravovat systémové protokoly",
+ "userperm_users": "Spravovat uživatele",
+ "userperm_api": "Spravovat všechny API klíče",
+
+ "fr_title": "Žádosti o soubory",
+ "fr_col_name": "Název",
+ "fr_col_uploaded_files": "Nahrané soubory",
+ "fr_col_total_size": "Celková velikost",
+ "fr_col_last_upload": "Poslední nahrání",
+ "fr_col_expiry": "Platnost",
+ "fr_col_user": "Uživatel",
+ "fr_col_actions": "Akce",
+ "fr_uploaded_files": "Nahrané soubory",
+ "fr_active_uploads": "Probíhající nahrávání",
+ "fr_file_limit": "Limit souborů",
+ "fr_expand_collapse": "Rozbalit / sbalit",
+ "fr_download_all": "Stáhnout vše",
+ "fr_edit_request": "Upravit žádost",
+ "fr_delete_title": "Smazat žádost o soubory",
+ "fr_delete_confirm": "Opravdu chcete smazat žádost o soubory ""?
Trvale se tím smaže také souvisejících souborů a tuto akci nelze vrátit zpět.",
+ "fr_modal_title": "Název",
+ "fr_modal_friendly_name": "Popisný název",
+ "fr_modal_max_files": "Max. souborů",
+ "fr_modal_max_size": "Max. velikost",
+ "fr_modal_expiry": "Platnost",
+ "fr_modal_notes": "Poznámky",
+ "fr_modal_notes_placeholder": "Poznámky k žádosti",
+ "fr_modal_e2e_notice": "Nahrané soubory nejsou koncově šifrované a budou na serveru uloženy nešifrovaně",
+ "fr_new_title": "Nová žádost o soubory",
+ "fr_edit_title": "Upravit žádost o soubory",
+ "fr_admins_only_unlimited": "Nastavit neomezenou hodnotu mohou pouze správci",
+
+ "logs_uptime": "Doba běhu",
+ "logs_cpu_load": "Zatížení CPU",
+ "logs_memory_usage": "Využití paměti",
+ "logs_disk_usage": "Využití disku",
+ "logs_data_served": "Přenesená data",
+ "logs_uploaded_files": "Nahrané soubory",
+ "logs_system_logs": "Systémové protokoly",
+ "logs_filter": "Filtr",
+ "logs_filter_all": "Všechny události",
+ "logs_filter_warning": "Varování",
+ "logs_filter_auth": "Ověřování",
+ "logs_filter_upload": "Nahrávání",
+ "logs_filter_edit": "Změny",
+ "logs_filter_download": "Stahování",
+ "logs_filter_info": "Informace",
+ "logs_reset_traffic": "Vynulovat přenosy",
+ "logs_reset_traffic_title": "Vynulovat statistiku přenesených dat",
+ "logs_delete_select": "Smazat protokoly...",
+ "logs_delete_30": "Starší než 30 dní",
+ "logs_delete_14": "Starší než 14 dní",
+ "logs_delete_7": "Starší než 7 dní",
+ "logs_delete_2": "Starší než 2 dny",
+ "logs_delete_all": "Smazat všechny protokoly",
+ "logs_execute": "Provést",
+ "logs_traffic_since": "Přenosy od {0}",
+ "logs_load_error": "Chyba při načítání protokolů. Podrobnosti najdete v konzoli.",
+ "logs_confirm_delete": "Opravdu chcete smazat vybrané protokoly?",
+ "logs_confirm_reset_traffic": "Opravdu chcete vynulovat statistiku přenosů?",
+
+ "download_decrypting": "Dešifrování...",
+ "download_encrypted": "Šifrováno",
+ "download_size": "Velikost",
+ "download_button": "Stáhnout",
+ "download_button_file": "Stáhnout soubor",
+ "download_error_cors": "Soubor nelze stáhnout. Kontaktujte prosím osobu, která jej nahrála. Možná příčina: nesprávná pravidla CORS při použití vzdáleného úložiště",
+ "downloadpw_title": "Vyžadováno heslo",
+ "downloadpw_placeholder": "Zadejte heslo",
+ "downloadpw_incorrect": "Nesprávné heslo!",
+ "hotlink_expired": "Platnost požadovaného souboru vypršela",
+
+ "e2e_title": "Nastavení koncového šifrování",
+ "e2e_page_title": "Nastavení E2E",
+ "e2e_password_intro": "Vaše heslo pro dešifrování je:",
+ "e2e_generating": "Generuje se.......",
+ "e2e_password_warning": "Uložte si toto heslo na bezpečné místo. Bez něj nebudete moci své soubory dešifrovat ani sdílet, pokud dojde ke smazání dat prohlížeče nebo se přihlásíte z jiného počítače! Toto heslo se zobrazí pouze jednou.",
+ "e2e_reset_hint": "Pokud potřebujete heslo obnovit, spusťte znovu průvodce nastavením Gokapi.",
+ "e2e_existing_setup": "Koncové šifrování již bylo nastaveno, ale na tomto počítači nebyl nalezen žádný klíč. Zadejte prosím heslo do pole níže. Pokud heslo pro dešifrování neznáte, obnovte je opětovným spuštěním průvodce nastavením Gokapi.",
+ "e2e_invalid_key_confirm": "Zdá se, že byl zadán neplatný klíč koncového šifrování. Chcete zadat správný?",
+ "e2e_error_get_info": "Při načítání informací o E2E: {0}",
+ "e2e_error_store_info": "Při ukládání informací o E2E: {0}",
+
+ "pu_title": "Nahrát soubory",
+ "pu_note": "Poznámka",
+ "pu_restrictions": "Omezení nahrávání",
+ "pu_restriction_until": "Nahrávat lze do:",
+ "pu_restriction_max_size": "Maximální velikost souboru:",
+ "pu_restriction_max_files": "Maximální počet souborů:",
+ "pu_dragdrop": "Přetáhněte soubory sem",
+ "pu_dragdrop_sub": "nebo je vložte či vyberte kliknutím",
+ "pu_upload_button": "Nahrát soubory",
+ "pu_error_title": "Nahrání se nezdařilo",
+ "pu_success_title": "Hotovo",
+ "pu_success_text": "Všechny soubory byly úspěšně nahrány. Další soubory už nelze nahrát. Tuto stránku můžete zavřít.",
+ "pu_status_ready": "Připraveno",
+ "pu_status_reserving": "Rezervace...",
+ "pu_status_waiting_slot": "Čekání na volné místo pro nahrání...",
+ "pu_status_completed": "Dokončeno",
+ "pu_status_retry": "Pokus {0}/3: {1}",
+ "pu_cancel_upload": "Zrušit nahrávání",
+ "pu_remove_from_list": "Odebrat ze seznamu",
+ "pu_upload_failed": "Nahrání se nezdařilo",
+ "pu_file_too_large": "Soubor „{0}“ překračuje maximální povolenou velikost {1}.",
+ "pu_too_many_files_single": "Je vybráno příliš mnoho souborů. Vyberte prosím pouze 1 soubor.",
+ "pu_too_many_files": "Je vybráno příliš mnoho souborů. Vyberte prosím nejvýše {0} souborů.",
+ "pu_max_files_dynamic": "Některé soubory se nepodařilo nahrát, protože server požadavek odmítl. Pravděpodobně nahrával soubory zároveň jiný uživatel a byl dosažen maximální počet souborů.",
+ "pu_request_expired": "Žádosti o soubory vypršel povolený časový limit a další soubory už nelze nahrát.",
+ "pu_err_size_limit": "Překročen limit velikosti souboru",
+ "pu_err_expired": "Platnost žádosti o soubory vypršela",
+ "pu_err_max_files": "Dosažen maximální počet souborů",
+ "pu_err_rate_limit": "Příliš mnoho požadavků, zkuste to prosím později",
+ "pu_err_unknown": "Neznámá chyba při nahrávání",
+ "pu_err_invalid_reserve": "Neplatná odpověď na rezervaci",
+
+ "status_processing": "Zpracování souboru...",
+ "status_saving": "Ukládání souboru...",
+ "status_finalising": "Dokončování...",
+ "status_in_queue": "Ve frontě...",
+ "status_error": "Chyba",
+ "status_unknown": "Neznámý stav",
+ "status_server_error": "Chyba serveru",
+ "upload_close_warning": "Nahrávání stále probíhá. Chcete tuto stránku zavřít?",
+ "upload_error_too_large": "Soubor je příliš velký pro nahrání. Pokud používáte reverzní proxy, ujistěte se, že je povolená velikost těla požadavku alespoň 70 MB.",
+
+ "error_prefix": "Chyba: ",
+ "error_generic": "Chyba: {0}",
+ "error_request_failed": "Požadavek selhal se stavem: {0}",
+ "error_invalid_token_response": "Neplatná odpověď při získávání tokenu",
+ "error_presign": "Nepodařilo se získat podepsaný odkaz",
+ "error_invalid_id": "Neplatné ID: smí obsahovat pouze číslice.",
+ "error_unable_download": "Nelze stáhnout: {0}",
+ "error_unable_set_permission": "Nelze nastavit oprávnění: {0}",
+ "error_unable_change_rank": "Nelze změnit úroveň uživatele: {0}",
+ "error_unable_delete_user": "Nelze smazat uživatele: {0}",
+ "error_unable_reset_password": "Nelze obnovit heslo uživatele: {0}",
+ "error_unable_create_user": "Nelze vytvořit uživatele: {0}",
+ "error_user_exists": "Uživatel s tímto jménem už existuje",
+ "error_unable_delete_api_key": "Nelze smazat API klíč: {0}",
+ "error_unable_create_api_key": "Nelze vytvořit API klíč: {0}",
+ "error_unable_save_name": "Nelze uložit název: {0}",
+ "error_unable_delete_file_request": "Nelze smazat žádost o soubory: {0}",
+ "error_unable_save_file_request": "Nelze uložit žádost o soubory: {0}",
+ "error_unable_delete_file": "Nelze smazat soubor: {0}",
+ "error_unable_restore_file": "Nelze obnovit soubor: {0}",
+ "error_unable_edit_file": "Nelze upravit soubor: {0}",
+ "error_unable_delete_logs": "Nelze smazat protokoly: {0}",
+ "error_unable_reset_stats": "Nelze vynulovat statistiky: {0}",
+
+ "errorpage_file_not_found_title": "Soubor nenalezen",
+ "errorpage_file_not_found_text": "Platnost odkazu mohla vypršet nebo byl soubor stažen příliš mnohokrát.",
+ "errorpage_upload_title": "Soubory nelze nahrát",
+ "errorpage_upload_text": "To může mít jednu z následujících příčin:",
+ "errorpage_upload_reason_expired": "Platnost žádosti o soubory vypršela (byl dosažen časový limit)",
+ "errorpage_upload_reason_limit": "Byl dosažen limit souborů pro tuto žádost",
+ "errorpage_upload_reason_url": "Byla odeslána neplatná adresa pro nahrání",
+ "errorpage_decryption_title": "Chybějící nebo neplatný dešifrovací klíč",
+ "errorpage_decryption_text": "Tento soubor je šifrovaný, ale nebyl zadán žádný klíč nebo je klíč neplatný.
Kontaktujte prosím osobu, která soubor nahrála, a ujistěte se, že používáte úplnou adresu včetně části za znakem #.",
+ "errorpage_unauthorised_title": "Neoprávněný uživatel",
+ "errorpage_unauthorised_text": "Přihlášení přes poskytovatele OAuth proběhlo úspěšně, tento uživatel však není oprávněn Gokapi používat.",
+ "errorpage_login_other_user": "Přihlásit se jako jiný uživatel",
+ "errorpage_oidc_title": "Chyba poskytovatele OIDC: {0}",
+ "errorpage_oidc_text": "Přihlášení přes poskytovatele OAuth se nezdařilo, byla hlášena tato chyba:",
+ "errorpage_try_again": "Zkusit znovu",
+ "errorpage_unknown_title": "Neznámá chyba",
+ "errorpage_unknown_text": "Gokapi nemůže tuto chybu zobrazit (kód chyby {0})",
+ "errorpage_unknown_id_title": "Neznámé ID chyby",
+ "errorpage_unknown_id_text": "Bohužel došlo k chybě a chybovou zprávu nebylo možné zobrazit.",
+ "errorpage_access_denied_title": "Přístup odepřen",
+ "errorpage_access_denied_text": "Požadavek byl zamítnut uživatelem nebo poskytovatelem ověření.",
+ "errorpage_login_failed_title": "Přihlášení se nezdařilo",
+ "errorpage_login_failed_text": "Byla hlášena tato chyba:",
+ "errorpage_no_login_info_title": "Neoprávněný přístup",
+ "errorpage_no_login_info_text": "Poskytovatel ověření neodeslal žádné přihlašovací údaje.",
+ "errorpage_oauth_no_state": "Parametr state nebyl poskytnut",
+ "errorpage_oauth_state_mismatch": "Parametr state neodpovídá",
+ "errorpage_oauth_exchange_failed": "Výměna tokenu se nezdařila",
+ "errorpage_oauth_userinfo_failed": "Načtení informací o uživateli se nezdařilo",
+ "errorpage_oauth_no_email": "Byla poskytnuta prázdná e-mailová adresa.\nUjistěte se prosím, že máte ve svém ověřovacím systému nastavenou e-mailovou adresu.",
+ "errorpage_oauth_login_failed": "Nepodařilo se pokračovat v přihlášení:"
+}
diff --git a/internal/languages/translations/en.json b/internal/languages/translations/en.json
new file mode 100644
index 00000000..93b9b6d0
--- /dev/null
+++ b/internal/languages/translations/en.json
@@ -0,0 +1,371 @@
+{
+ "language_name": "English",
+
+ "generic_unlimited": "Unlimited",
+ "generic_never": "Never",
+ "generic_none": "None",
+ "generic_online": "Online",
+ "generic_expired": "Expired",
+ "generic_language": "Language",
+ "generic_loading": "Loading...",
+ "generic_total": "Total",
+
+ "btn_close": "Close",
+ "btn_cancel": "Cancel",
+ "btn_save": "Save",
+ "btn_save_changes": "Save changes",
+ "btn_confirm": "Confirm",
+ "btn_ok": "OK",
+ "btn_continue": "Continue",
+ "btn_delete": "Delete",
+ "btn_edit": "Edit",
+ "btn_download": "Download",
+ "btn_share": "Share",
+ "btn_copy_url": "Copy URL",
+ "btn_copy_hotlink": "Copy hotlink",
+ "btn_hotlink": "Hotlink",
+ "btn_hotlink_unavailable": "Hotlink not available",
+ "btn_qr_code": "QR Code",
+ "btn_open_qr_code": "Open QR Code",
+ "btn_email": "Email",
+ "btn_share_email": "Share via email",
+ "btn_url": "URL",
+ "btn_remove": "Remove",
+
+ "nav_upload": "Upload",
+ "nav_file_requests": "File Requests",
+ "nav_users": "Users",
+ "nav_api": "API",
+ "nav_status": "Status",
+ "nav_logout": "Logout",
+ "nav_admin_title": "Admin",
+
+ "footer_powered_by": "Powered by",
+
+ "login_title": "Login",
+ "login_username": "Username",
+ "login_password": "Password",
+ "login_expired": "The login page was open too long and expired. Please try again.",
+ "login_incorrect": "Incorrect username or password!",
+ "login_forgot_password": "Forgot password",
+
+ "forgotpw_title": "Forgot password",
+ "forgotpw_text": "If you forgot your user password, please ask your administrator to reset it.
To reset an administrator password, restart the server with the argument --reconfigure and change it in the authentication section.",
+
+ "changepw_title": "Change Password",
+ "changepw_text": "A password change has been requested. Please enter a new password.",
+ "changepw_new_password": "New Password",
+ "changepw_confirm_password": "Confirm New Password",
+ "changepw_not_matching": "Passwords do not match",
+ "changepw_error_form": "Invalid form data sent",
+ "changepw_error_csrf": "Form was not submitted completely",
+ "changepw_error_too_short": "Password is too short",
+ "changepw_error_same": "New password has to be different from the old password",
+
+ "upload_title": "Upload",
+ "upload_please_wait": "Please wait",
+ "upload_loading_e2e": "Loading end-to-end encryption...",
+ "upload_dragdrop": "Drag & drop files here",
+ "upload_dragdrop_sub": "or paste or click to select.",
+ "upload_dragdrop_sub_e2e": "or paste or click to upload with end-to-end encryption",
+ "upload_option_download_limit": "Download Limit",
+ "upload_option_download_limit_enable": "Enable Download Limit",
+ "upload_option_downloads": "Downloads",
+ "upload_option_expiry": "Expiry",
+ "upload_option_time_limit_enable": "Enable Time Limit",
+ "upload_option_days": "Days",
+ "upload_option_password": "Password",
+ "upload_option_password_enable": "Enable Password Protection",
+ "upload_option_no_password": "No password",
+ "duration_years": "y",
+ "duration_days": "d",
+ "duration_hours": "h",
+ "duration_minutes": "m",
+ "duration_seconds": "s",
+ "dropzone_file_too_big": "File is too big ({{filesize}}MiB). Max filesize: {{maxFilesize}}MiB.",
+ "dropzone_no_dragdrop": "Your browser does not support drag and drop file uploads.",
+
+ "filetable_filename": "Filename",
+ "filetable_size": "Size",
+ "filetable_downloads_remaining": "Downloads remaining",
+ "filetable_stored_until": "Stored until",
+ "filetable_downloads": "Downloads",
+ "filetable_id": "ID",
+ "filetable_actions": "Actions",
+ "filetable_password_protected": "Password protected",
+ "filetable_files_stored": "Files stored: {0}",
+ "filetable_empty": "No files stored yet",
+ "datatable_search": "Search:",
+ "datatable_search_placeholder": "Filename or ID",
+ "datatable_zero_records": "No matching files found",
+ "datatable_info_filtered": "(filtered from _MAX_ files in total)",
+ "datatable_sort_ascending": ": activate to sort column ascending",
+ "datatable_sort_descending": ": activate to sort column descending",
+
+ "toast_url_copied": "URL copied to clipboard",
+ "toast_api_key_copied": "API key copied to clipboard",
+ "toast_password_copied": "Password copied to clipboard",
+ "toast_invalid": "Invalid notification",
+ "toast_file_deleted": "File deleted:",
+ "toast_restore": "Restore",
+ "toast_deprecation": "Warning! This server is using a deprecated feature. Detailed information can be found in the system logs.",
+ "toast_pw_change_required_set": "Password change requirement set successfully",
+ "toast_also_granting_replace": "Also granting permission to replace own files",
+ "toast_also_revoking_replace": "Also revoking permission to replace files of other users",
+
+ "editmodal_title": "Filename",
+ "editmodal_limit_downloads": "Limit Downloads",
+ "editmodal_limit_downloads_aria": "Limit downloads",
+ "editmodal_downloads_remaining": "Downloads Remaining",
+ "editmodal_expiry": "Expiry",
+ "editmodal_expire_files": "Expire files",
+ "editmodal_password": "Password",
+ "editmodal_require_password": "Require password",
+ "editmodal_replace_content": "Replace Content",
+ "editmodal_replace_file_content": "Replace file content",
+ "editmodal_replace_aria": "Replace File Content",
+ "editmodal_password_unchanged": "(unchanged)",
+ "editmodal_replace_unavailable": "Unavailable",
+ "editmodal_replace_e2e_notice": "Replacing content is not available for end-to-end encrypted files",
+
+ "api_title": "API Keys",
+ "api_hint": "Please visit the API documentation for more information about the API. Click on the API key name to give it a new name. Permissions can be changed by clicking on them.",
+ "api_col_name": "Name",
+ "api_col_key": "API Key",
+ "api_col_last_used": "Last Used",
+ "api_col_permissions": "Permissions",
+ "api_col_user": "User",
+ "api_col_actions": "Actions",
+ "api_unnamed_key": "Unnamed key",
+ "api_copy_key": "Copy API Key",
+
+ "perm_legend_title": "Permission Legend",
+ "perm_legend_show": "Show permission legend",
+
+ "apiperm_view": "List uploads",
+ "apiperm_upload": "Upload files",
+ "apiperm_edit": "Edit uploads",
+ "apiperm_delete": "Delete uploads",
+ "apiperm_replace": "Replace uploads",
+ "apiperm_download": "Download files",
+ "apiperm_file_requests": "Manage file requests",
+ "apiperm_users": "Manage users",
+ "apiperm_logs": "Manage system logs",
+ "apiperm_api": "Manage API keys",
+
+ "users_title": "Users",
+ "users_col_user": "User",
+ "users_col_group": "Group",
+ "users_col_last_online": "Last online",
+ "users_col_uploads": "Uploads",
+ "users_col_permissions": "Permissions",
+ "users_col_actions": "Actions",
+ "userlevel_super_admin": "Super Admin",
+ "userlevel_admin": "Admin",
+ "userlevel_user": "User",
+ "userlevel_invalid": "Invalid",
+ "users_reset_password": "Reset Password",
+ "users_promote": "Promote User",
+ "users_demote": "Demote User",
+ "users_delete_title": "Delete User",
+ "users_delete_confirm": "Are you sure you want to delete user ? This action cannot be undone.",
+ "users_delete_files": "Permanently delete all files uploaded by this user.",
+ "users_delete_button": "Delete User",
+ "users_new_title": "Create New User",
+ "users_new_username": "Username",
+ "users_new_username_placeholder": "Enter a username",
+ "users_new_button": "Add User",
+ "users_resetpw_title": "Reset Password",
+ "users_resetpw_intro": "Choose an option to reset the password for the user :",
+ "users_resetpw_force": "Force user to set a new password on next login",
+ "users_resetpw_random": "Generate a new random password (user will be forced to change it on next login)",
+ "users_resetpw_new": "New Password:",
+ "users_resetpw_copy": "Copy Password",
+
+ "userperm_file_requests": "Create file requests",
+ "userperm_replace_own": "Replace own uploads",
+ "userperm_list_other": "List other uploads",
+ "userperm_edit_other": "Edit other uploads",
+ "userperm_delete_other": "Delete other uploads",
+ "userperm_replace_other": "Replace other uploads",
+ "userperm_logs": "Manage system logs",
+ "userperm_users": "Manage users",
+ "userperm_api": "Manage all API keys",
+
+ "fr_title": "File Requests",
+ "fr_col_name": "Name",
+ "fr_col_uploaded_files": "Uploaded Files",
+ "fr_col_total_size": "Total Size",
+ "fr_col_last_upload": "Last Upload",
+ "fr_col_expiry": "Expiry",
+ "fr_col_user": "User",
+ "fr_col_actions": "Actions",
+ "fr_uploaded_files": "Uploaded files",
+ "fr_active_uploads": "Active uploads",
+ "fr_file_limit": "File limit",
+ "fr_expand_collapse": "Expand / Collapse",
+ "fr_download_all": "Download all",
+ "fr_edit_request": "Edit request",
+ "fr_delete_title": "Delete File Request",
+ "fr_delete_confirm": "Are you sure you want to delete the filerequest ""?
This also permanently deletes associated file(s) and cannot be undone.",
+ "fr_modal_title": "Title",
+ "fr_modal_friendly_name": "Friendly name",
+ "fr_modal_max_files": "Max Files",
+ "fr_modal_max_size": "Max Size",
+ "fr_modal_expiry": "Expiry",
+ "fr_modal_notes": "Notes",
+ "fr_modal_notes_placeholder": "Notes about the request",
+ "fr_modal_e2e_notice": "Uploaded files are not end-to-end encrypted and will be stored in plain text on the server",
+ "fr_new_title": "New File Request",
+ "fr_edit_title": "Edit File Request",
+ "fr_admins_only_unlimited": "Only admins can set this to unlimited",
+
+ "logs_uptime": "Uptime",
+ "logs_cpu_load": "CPU Load",
+ "logs_memory_usage": "Memory Usage",
+ "logs_disk_usage": "Disk Usage",
+ "logs_data_served": "Data Served",
+ "logs_uploaded_files": "Uploaded Files",
+ "logs_system_logs": "System Logs",
+ "logs_filter": "Filter",
+ "logs_filter_all": "All Events",
+ "logs_filter_warning": "Warnings",
+ "logs_filter_auth": "Authentication",
+ "logs_filter_upload": "Uploads",
+ "logs_filter_edit": "Modifications",
+ "logs_filter_download": "Downloads",
+ "logs_filter_info": "Info",
+ "logs_reset_traffic": "Reset Traffic",
+ "logs_reset_traffic_title": "Reset the traffic statistic",
+ "logs_delete_select": "Delete Logs...",
+ "logs_delete_30": "Older than 30 days",
+ "logs_delete_14": "Older than 14 days",
+ "logs_delete_7": "Older than 7 days",
+ "logs_delete_2": "Older than 2 days",
+ "logs_delete_all": "Delete all logs",
+ "logs_execute": "Execute",
+ "logs_traffic_since": "Traffic since {0}",
+ "logs_load_error": "Error loading logs. See console for details.",
+ "logs_confirm_delete": "Do you want to delete the selected logs?",
+ "logs_confirm_reset_traffic": "Do you want to reset the traffic statistics?",
+
+ "download_decrypting": "Decrypting...",
+ "download_encrypted": "Encrypted",
+ "download_size": "Size",
+ "download_button": "Download",
+ "download_button_file": "Download File",
+ "download_error_cors": "Unable to download file. Please contact the uploader. Possible problem: Incorrect CORS rules, if using remote storage",
+ "downloadpw_title": "Password required",
+ "downloadpw_placeholder": "Enter password",
+ "downloadpw_incorrect": "Incorrect password!",
+ "hotlink_expired": "The requested file has expired",
+
+ "e2e_title": "End-to-End Encryption Setup",
+ "e2e_page_title": "E2E Setup",
+ "e2e_password_intro": "Your password for decryption is:",
+ "e2e_generating": "Generating.......",
+ "e2e_password_warning": "Save this password to a secure location, without it you will not be able to decrypt/share your files if your browser data gets deleted or you login from a different machine! This password will only be shown once.",
+ "e2e_reset_hint": "If you need to reset the password, run the Gokapi setup again.",
+ "e2e_existing_setup": "End-to-end encryption has been set up, however no key was found on the local machine. Please enter the password in the text field below. If you do not know the decryption password, please re-run the Gokapi setup to reset the password.",
+ "e2e_invalid_key_confirm": "It appears that an invalid end-to-end encryption key has been entered. Would you like to enter the correct one?",
+ "e2e_error_get_info": "Trying to get E2E info: {0}",
+ "e2e_error_store_info": "Trying to store E2E info: {0}",
+
+ "pu_title": "Upload Files",
+ "pu_note": "Note",
+ "pu_restrictions": "Upload restrictions",
+ "pu_restriction_until": "Upload possible until:",
+ "pu_restriction_max_size": "Maximum file size:",
+ "pu_restriction_max_files": "Maximum number of files:",
+ "pu_dragdrop": "Drag & drop files here",
+ "pu_dragdrop_sub": "or paste or click to select",
+ "pu_upload_button": "Upload Files",
+ "pu_error_title": "Unable to upload",
+ "pu_success_title": "Success",
+ "pu_success_text": "All files have been successfully uploaded. No further files can be uploaded anymore. You can close this page now.",
+ "pu_status_ready": "Ready",
+ "pu_status_reserving": "Reserving...",
+ "pu_status_waiting_slot": "Waiting for upload slot...",
+ "pu_status_completed": "Completed",
+ "pu_status_retry": "Retry {0}/3: {1}",
+ "pu_cancel_upload": "Cancel Upload",
+ "pu_remove_from_list": "Remove from list",
+ "pu_upload_failed": "Upload failed",
+ "pu_file_too_large": "The file \"{0}\" exceeds the maximum allowed size of {1}.",
+ "pu_too_many_files_single": "Too many files are selected for upload. Please only select 1 file.",
+ "pu_too_many_files": "Too many files are selected for upload. Please only select {0} files or fewer.",
+ "pu_max_files_dynamic": "Some files could not be uploaded because the server rejected the request. This likely occurred because another user was uploading files at the same time and the maximum file limit was reached.",
+ "pu_request_expired": "The upload request exceeded the permitted time limit, and uploading additional files is no longer possible.",
+ "pu_err_size_limit": "File size limit exceeded",
+ "pu_err_expired": "Upload request has expired",
+ "pu_err_max_files": "Maximum file count reached",
+ "pu_err_rate_limit": "Too many requests, please try again later",
+ "pu_err_unknown": "Unknown upload error",
+ "pu_err_invalid_reserve": "Invalid reserve response",
+
+ "status_processing": "Processing file...",
+ "status_saving": "Saving file...",
+ "status_finalising": "Finalising...",
+ "status_in_queue": "In Queue...",
+ "status_error": "Error",
+ "status_unknown": "Unknown status",
+ "status_server_error": "Server Error",
+ "upload_close_warning": "Upload is still in progress. Do you want to close this page?",
+ "upload_error_too_large": "File too large to upload. If you are using a reverse proxy, make sure that the allowed body size is at least 70MB.",
+
+ "error_prefix": "Error: ",
+ "error_generic": "Error: {0}",
+ "error_request_failed": "Request failed with status: {0}",
+ "error_invalid_token_response": "Invalid response when trying to get token",
+ "error_presign": "Unable to get presigned key",
+ "error_invalid_id": "Invalid ID: must contain only digits.",
+ "error_unable_download": "Unable to download: {0}",
+ "error_unable_set_permission": "Unable to set permission: {0}",
+ "error_unable_change_rank": "Unable to change rank: {0}",
+ "error_unable_delete_user": "Unable to delete user: {0}",
+ "error_unable_reset_password": "Unable to reset user password: {0}",
+ "error_unable_create_user": "Unable to create user: {0}",
+ "error_user_exists": "A user already exists with that name",
+ "error_unable_delete_api_key": "Unable to delete API key: {0}",
+ "error_unable_create_api_key": "Unable to create API key: {0}",
+ "error_unable_save_name": "Unable to save name: {0}",
+ "error_unable_delete_file_request": "Unable to delete file request: {0}",
+ "error_unable_save_file_request": "Unable to save file request: {0}",
+ "error_unable_delete_file": "Unable to delete file: {0}",
+ "error_unable_restore_file": "Unable to restore file: {0}",
+ "error_unable_edit_file": "Unable to edit file: {0}",
+ "error_unable_delete_logs": "Unable to delete logs: {0}",
+ "error_unable_reset_stats": "Unable to reset stats: {0}",
+
+ "errorpage_file_not_found_title": "File not found",
+ "errorpage_file_not_found_text": "The link may have expired or the file has been downloaded too many times.",
+ "errorpage_upload_title": "Unable to upload files",
+ "errorpage_upload_text": "This can happen for one of the following reasons:",
+ "errorpage_upload_reason_expired": "The upload request has expired (time limit reached)",
+ "errorpage_upload_reason_limit": "The file limit for this upload request has been reached",
+ "errorpage_upload_reason_url": "An invalid upload URL was submitted",
+ "errorpage_decryption_title": "Missing or invalid decryption key",
+ "errorpage_decryption_text": "This file is encrypted, but no key was provided or the key is invalid.
Please contact the uploader and make sure the complete URL is used, including the value after the hash.",
+ "errorpage_unauthorised_title": "Unauthorised user",
+ "errorpage_unauthorised_text": "Login with OAuth provider was sucessful, however this user is not authorised to use Gokapi.",
+ "errorpage_login_other_user": "Log in as different user",
+ "errorpage_oidc_title": "OIDC Provider Error: {0}",
+ "errorpage_oidc_text": "Login with OAuth provider was not sucessful, the following error was raised:",
+ "errorpage_try_again": "Try again",
+ "errorpage_unknown_title": "Unknown error",
+ "errorpage_unknown_text": "Gokapi cannot display this error (error code {0})",
+ "errorpage_unknown_id_title": "Unknown error ID",
+ "errorpage_unknown_id_text": "Unfortunately, an error occurred and the error message could not be displayed.",
+ "errorpage_access_denied_title": "Access denied",
+ "errorpage_access_denied_text": "The request was denied by the user or authentication provider.",
+ "errorpage_login_failed_title": "Unable to log in",
+ "errorpage_login_failed_text": "The following error was raised:",
+ "errorpage_no_login_info_title": "Unauthorised",
+ "errorpage_no_login_info_text": "No login information was sent from the authentication provider.",
+ "errorpage_oauth_no_state": "Parameter state was not provided",
+ "errorpage_oauth_state_mismatch": "Parameter state did not match",
+ "errorpage_oauth_exchange_failed": "Failed to exchange token",
+ "errorpage_oauth_userinfo_failed": "Failed to get user info",
+ "errorpage_oauth_no_email": "An empty email address was provided.\nPlease make sure that you have your email address set in your authentication user backend.",
+ "errorpage_oauth_login_failed": "Failed to continue with login:"
+}
diff --git a/internal/webserver/Webserver.go b/internal/webserver/Webserver.go
index 6b707e5b..0b067960 100644
--- a/internal/webserver/Webserver.go
+++ b/internal/webserver/Webserver.go
@@ -12,6 +12,7 @@ import (
"encoding/base64"
"errors"
"fmt"
+ "html"
"html/template"
"io"
"io/fs"
@@ -29,6 +30,7 @@ import (
"github.com/forceu/gokapi/internal/encryption"
"github.com/forceu/gokapi/internal/environment"
"github.com/forceu/gokapi/internal/helper"
+ "github.com/forceu/gokapi/internal/languages"
"github.com/forceu/gokapi/internal/logging"
"github.com/forceu/gokapi/internal/logging/serverstats"
"github.com/forceu/gokapi/internal/models"
@@ -85,8 +87,8 @@ var templateFolder *template.Template
// customStaticInfo is passed to all templates, so custom CSS or JS can be embedded
var customStaticInfo customStatic
-// imageExpiredPicture is sent for an expired hotlink
-var imageExpiredPicture []byte
+// imageExpiredPicture is sent for an expired hotlink, indexed by language code
+var imageExpiredPicture map[string][]byte
// srv is the web server that is used for this module
var srv http.Server
@@ -184,15 +186,36 @@ func handleFavicon(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(icon)
}
+// loadExpiryImage renders the image that is shown for expired hotlinks. As the
+// image is static apart from the text, one version per available language is
+// rendered on startup instead of rendering it for every request.
func loadExpiryImage() {
svgTemplate, err := templatetext.ParseFS(templateFolderEmbedded, "web/templates/expired_file_svg.tmpl")
helper.Check(err)
- var buf bytes.Buffer
- err = svgTemplate.Execute(&buf, struct {
- PublicName string
- }{PublicName: configuration.Get().PublicName})
- helper.Check(err)
- imageExpiredPicture = buf.Bytes()
+ imageExpiredPicture = make(map[string][]byte)
+ for _, language := range languages.GetAvailable() {
+ var buf bytes.Buffer
+ // The template is a text template, therefore the values need to be
+ // escaped manually so that the SVG stays valid
+ err = svgTemplate.Execute(&buf, struct {
+ PublicName string
+ ExpiredText string
+ }{
+ PublicName: html.EscapeString(configuration.Get().PublicName),
+ ExpiredText: html.EscapeString(languages.Get(language.Code).T("hotlink_expired")),
+ })
+ helper.Check(err)
+ imageExpiredPicture[language.Code] = buf.Bytes()
+ }
+}
+
+// getExpiryImage returns the image for expired hotlinks in the language of the request
+func getExpiryImage(r *http.Request) []byte {
+ image, ok := imageExpiredPicture[languages.FromRequest(r).Code]
+ if !ok {
+ return imageExpiredPicture[languages.DefaultCode]
+ }
+ return image
}
// Shutdown closes the webserver gracefully
@@ -212,6 +235,7 @@ func initTemplates(templateFolderEmbedded embed.FS) {
funcMap := template.FuncMap{
"newAdminButtonContext": newAdminButtonContext,
+ "newFileRequestContext": newFileRequestContext,
}
if helper.FolderExists("templates") {
fmt.Println("Found folder 'templates', using local folder instead of internal template folder")
@@ -241,6 +265,7 @@ type redirectValues struct {
PublicName string
BaseUrl string
PasswordRequired bool
+ Lang languages.Translator
}
// Handling of /id/?/? - used when filename shall be displayed, will redirect to the regular download URL
@@ -261,7 +286,8 @@ func redirectFromFilename(w http.ResponseWriter, r *http.Request) {
Size: file.Size,
PublicName: config.PublicName,
BaseUrl: config.ServerUrl,
- PasswordRequired: file.PasswordHash != ""})
+ PasswordRequired: file.PasswordHash != "",
+ Lang: languages.FromRequest(r)})
helper.CheckIgnoreTimeout(err)
}
@@ -292,7 +318,8 @@ func doLogout(w http.ResponseWriter, r *http.Request) {
func showIndex(w http.ResponseWriter, r *http.Request) {
err := templateFolder.ExecuteTemplate(w, "index", genericView{RedirectUrl: configuration.Get().RedirectUrl,
PublicName: configuration.Get().PublicName,
- CustomContent: customStaticInfo})
+ CustomContent: customStaticInfo,
+ Lang: languages.FromRequest(r)})
helper.CheckIgnoreTimeout(err)
}
@@ -319,6 +346,7 @@ func handleGenerateAuthToken(w http.ResponseWriter, r *http.Request) {
// Handling of /changePassword
func changePassword(w http.ResponseWriter, r *http.Request) {
var errMessage string
+ translator := languages.FromRequest(r)
user, err := authentication.GetUserFromRequest(r)
if err != nil {
panic(err)
@@ -331,7 +359,7 @@ func changePassword(w http.ResponseWriter, r *http.Request) {
if err != nil {
fmt.Println("Invalid form data sent to server for /changePassword")
fmt.Println(err)
- errMessage = "Invalid form data sent"
+ errMessage = translator.T("changepw_error_form")
} else {
var ok bool
var pwHash string
@@ -340,7 +368,7 @@ func changePassword(w http.ResponseWriter, r *http.Request) {
csrf := r.PostForm.Get("csrf-token")
pwHash, ok, err = validateNewPassword(pw, user, csrf)
if err != nil {
- errMessage = firstLetterUpper(err.Error())
+ errMessage = translator.T(passwordErrorKey(err))
}
if ok {
user.Password = pwHash
@@ -356,10 +384,34 @@ func changePassword(w http.ResponseWriter, r *http.Request) {
MinPasswordLength: configuration.GetEnvironment().MinLengthPassword,
ErrorMessage: errMessage,
CustomContent: customStaticInfo,
+ Lang: translator,
CsrfToken: csrftoken.Generate(csrftoken.TypeLogin)})
helper.CheckIgnoreTimeout(err)
}
+var (
+ // errPasswordCsrfInvalid is returned if the CSRF token of the password change form was invalid
+ errPasswordCsrfInvalid = errors.New("form was not submitted completely")
+ // errPasswordTooShort is returned if the new password is shorter than the required minimum length
+ errPasswordTooShort = errors.New("password is too short")
+ // errPasswordUnchanged is returned if the new password is identical to the old one
+ errPasswordUnchanged = errors.New("new password has to be different from the old password")
+)
+
+// passwordErrorKey returns the translation key for an error returned by validateNewPassword
+func passwordErrorKey(err error) string {
+ switch {
+ case errors.Is(err, errPasswordCsrfInvalid):
+ return "changepw_error_csrf"
+ case errors.Is(err, errPasswordTooShort):
+ return "changepw_error_too_short"
+ case errors.Is(err, errPasswordUnchanged):
+ return "changepw_error_same"
+ default:
+ return "changepw_error_form"
+ }
+}
+
// validateNewPassword validates the new password and returns the new password hash if the password is valid.
// If the password is not valid, it returns an error message and an empty string.
// If the password is valid, it returns the hash as a string and true.
@@ -368,26 +420,19 @@ func validateNewPassword(newPassword string, user models.User, userCsrfToken str
return user.Password, false, nil
}
if !csrftoken.IsValid(csrftoken.TypeLogin, userCsrfToken) {
- return "", false, errors.New("form was not submitted completely")
+ return "", false, errPasswordCsrfInvalid
}
if len(newPassword) < configuration.GetEnvironment().MinLengthPassword {
- return "", false, errors.New("password is too short")
+ return "", false, errPasswordTooShort
}
isSame, _ := configuration.VerifyPassword(newPassword, user.Password, "")
if isSame {
- return "", false, errors.New("new password has to be different from the old password")
+ return "", false, errPasswordUnchanged
}
newPasswordHash := configuration.HashPassword(newPassword, false, "")
return newPasswordHash, true, nil
}
-func firstLetterUpper(s string) string {
- if len(s) == 0 {
- return s
- }
- return strings.ToUpper(string(s[0])) + s[1:]
-}
-
// Handling of /error
func showError(w http.ResponseWriter, r *http.Request) {
@@ -399,15 +444,17 @@ func showError(w http.ResponseWriter, r *http.Request) {
displayedError.CardWidth = "25rem"
}
+ translator := languages.FromRequest(r)
err := templateFolder.ExecuteTemplate(w, "error", genericView{
ErrorId: displayedError.ErrorId,
ErrorCardWidth: displayedError.CardWidth,
IsGenericError: displayedError.IsGeneric,
- ErrorTitle: displayedError.Title,
- ErrorMessage: displayedError.Message,
+ ErrorTitle: displayedError.GetTitle(translator),
+ ErrorMessage: displayedError.GetMessage(translator),
ErrorOauthMessage: displayedError.OAuthProviderMessage,
PublicName: configuration.Get().PublicName,
- CustomContent: customStaticInfo})
+ CustomContent: customStaticInfo,
+ Lang: translator})
helper.CheckIgnoreTimeout(err)
}
@@ -415,7 +462,8 @@ func showError(w http.ResponseWriter, r *http.Request) {
func forgotPassword(w http.ResponseWriter, r *http.Request) {
err := templateFolder.ExecuteTemplate(w, "forgotpw", genericView{
PublicName: configuration.Get().PublicName,
- CustomContent: customStaticInfo})
+ CustomContent: customStaticInfo,
+ Lang: languages.FromRequest(r)})
helper.CheckIgnoreTimeout(err)
}
@@ -425,7 +473,7 @@ func showUploadRequest(w http.ResponseWriter, r *http.Request) {
if err != nil {
panic(err)
}
- view := (&AdminView{}).convertGlobalConfig(ViewFileRequests, userId)
+ view := (&AdminView{}).convertGlobalConfig(ViewFileRequests, userId, languages.FromRequest(r))
if !view.ActiveUser.HasPermissionCreateFileRequests() {
redirect(w, r, "admin")
@@ -442,7 +490,7 @@ func showApiAdmin(w http.ResponseWriter, r *http.Request) {
if err != nil {
panic(err)
}
- view := (&AdminView{}).convertGlobalConfig(ViewAPI, userId)
+ view := (&AdminView{}).convertGlobalConfig(ViewAPI, userId, languages.FromRequest(r))
if configuration.GetEnvironment().DisableApiMenu && !view.ActiveUser.IsAdmin() {
redirect(w, r, "admin")
@@ -460,7 +508,7 @@ func showUserAdmin(w http.ResponseWriter, r *http.Request) {
if err != nil {
panic(err)
}
- view := (&AdminView{}).convertGlobalConfig(ViewUsers, userId)
+ view := (&AdminView{}).convertGlobalConfig(ViewUsers, userId, languages.FromRequest(r))
if !view.ActiveUser.HasPermissionManageUsers() || configuration.Get().Authentication.Method == models.AuthenticationDisabled {
redirect(w, r, "admin")
return
@@ -480,7 +528,7 @@ func processApi(w http.ResponseWriter, r *http.Request) {
func showLogin(w http.ResponseWriter, r *http.Request) {
_, ok, err := authentication.IsAuthenticated(w, r)
if err != nil {
- errorHandling.RedirectToErrorPage(w, r, "Unable to log in", "The following error was raised: "+err.Error(), errorHandling.WidthDefault)
+ errorHandling.RedirectToErrorPage(w, r, "errorpage_login_failed_title", "errorpage_login_failed_text", err.Error(), errorHandling.WidthDefault)
return
}
if ok {
@@ -488,8 +536,8 @@ func showLogin(w http.ResponseWriter, r *http.Request) {
return
}
if configuration.Get().Authentication.Method == models.AuthenticationHeader {
- errorHandling.RedirectToErrorPage(w, r, "Unauthorised",
- "No login information was sent from the authentication provider.", errorHandling.WidthDefault)
+ errorHandling.RedirectToErrorPage(w, r, "errorpage_no_login_info_title",
+ "errorpage_no_login_info_text", "", errorHandling.WidthDefault)
return
}
if configuration.Get().Authentication.Method == models.AuthenticationOAuth2 {
@@ -536,6 +584,7 @@ func showLogin(w http.ResponseWriter, r *http.Request) {
PublicName: configuration.Get().PublicName,
CustomContent: customStaticInfo,
CsrfToken: csrftoken.Generate(csrftoken.TypeLogin),
+ Lang: languages.FromRequest(r),
})
helper.CheckIgnoreTimeout(err)
}
@@ -550,6 +599,7 @@ type LoginView struct {
PublicName string
CsrfToken string
CustomContent customStatic
+ Lang languages.Translator
}
// Handling of /d
@@ -577,6 +627,7 @@ func showDownload(w http.ResponseWriter, r *http.Request) {
IsFailedLogin: false,
UsesHttps: configuration.UsesHttps(),
CustomContent: customStaticInfo,
+ Lang: languages.FromRequest(r),
}
if file.RequiresClientDecryption() {
@@ -634,14 +685,14 @@ func showHotlink(w http.ResponseWriter, r *http.Request) {
file, ok := storage.GetFileByHotlink(hotlinkId)
if !ok || file.IsFileRequest() {
w.Header().Set("Content-Type", "image/svg+xml")
- _, _ = w.Write(imageExpiredPicture)
+ _, _ = w.Write(getExpiryImage(r))
return
}
validFile := storage.ServeFile(file, w, r, false, true, false, true)
if !validFile {
// Only called if the file has already expired during the expiry check of storage.ServeFile()
w.Header().Set("Content-Type", "image/svg+xml")
- _, _ = w.Write(imageExpiredPicture)
+ _, _ = w.Write(getExpiryImage(r))
return
}
}
@@ -673,7 +724,7 @@ func showAdminMenu(w http.ResponseWriter, r *http.Request) {
}
}
- view := (&AdminView{}).convertGlobalConfig(ViewMain, user)
+ view := (&AdminView{}).convertGlobalConfig(ViewMain, user, languages.FromRequest(r))
if len(configuration.GetEnvironment().ActiveDeprecations) > 0 {
if user.IsSuperAdmin() {
view.ShowDeprecationNotice = true
@@ -691,7 +742,7 @@ func showLogs(w http.ResponseWriter, r *http.Request) {
if err != nil {
panic(err)
}
- view := (&AdminView{}).convertGlobalConfig(ViewLogs, user)
+ view := (&AdminView{}).convertGlobalConfig(ViewLogs, user, languages.FromRequest(r))
if !view.ActiveUser.HasPermissionManageLogs() {
redirect(w, r, "admin")
return
@@ -714,7 +765,8 @@ func showE2ESetup(w http.ResponseWriter, r *http.Request) {
err = templateFolder.ExecuteTemplate(w, "e2esetup", e2ESetupView{
HasBeenSetup: e2einfo.HasBeenSetUp(),
PublicName: configuration.Get().PublicName,
- CustomContent: customStaticInfo})
+ CustomContent: customStaticInfo,
+ Lang: languages.FromRequest(r)})
helper.CheckIgnoreTimeout(err)
}
@@ -734,6 +786,7 @@ type DownloadView struct {
EndToEndEncryption bool
UsesHttps bool
CustomContent customStatic
+ Lang languages.Translator
}
type e2ESetupView struct {
@@ -742,6 +795,7 @@ type e2ESetupView struct {
HasBeenSetup bool
PublicName string
CustomContent customStatic
+ Lang languages.Translator
}
// AdminView contains parameters for all admin-related pages
@@ -786,6 +840,7 @@ type AdminView struct {
TotalTraffic uint64
CustomContent customStatic
+ Lang languages.Translator
}
// getUserMap needs to return the map with pointers; otherwise template cannot call
@@ -814,7 +869,7 @@ const (
// Converts the globalConfig variable to an AdminView struct to pass the infos to
// the admin template
-func (u *AdminView) convertGlobalConfig(view int, user models.User) *AdminView {
+func (u *AdminView) convertGlobalConfig(view int, user models.User, translator languages.Translator) *AdminView {
var metaDataList []models.FileApiOutput
var apiKeyList []models.ApiKey
@@ -823,6 +878,7 @@ func (u *AdminView) convertGlobalConfig(view int, user models.User) *AdminView {
u.ActiveUser = user
u.UserMap = getUserMap()
u.CustomContent = customStaticInfo
+ u.Lang = translator
switch view {
case ViewMain:
for _, element := range database.GetAllMetadata() {
@@ -989,6 +1045,7 @@ func showPublicUpload(w http.ResponseWriter, r *http.Request) {
ChunkSize: config.ChunkSize,
MaxServerSize: config.MaxFileSizeMB,
FileRequest: &request,
+ Lang: languages.FromRequest(r),
CustomContent: customStaticInfo,
}
@@ -1108,7 +1165,7 @@ func requireLogin(next http.HandlerFunc, isUiCall, isPwChangeView bool) http.Han
addNoCacheHeader(w)
user, isLoggedIn, err := authentication.IsAuthenticated(w, r)
if err != nil {
- errorHandling.RedirectToErrorPage(w, r, "Unable to log in", "The following error was raised: "+err.Error(), errorHandling.WidthDefault)
+ errorHandling.RedirectToErrorPage(w, r, "errorpage_login_failed_title", "errorpage_login_failed_text", err.Error(), errorHandling.WidthDefault)
return
}
if isLoggedIn {
@@ -1135,11 +1192,22 @@ func requireLogin(next http.HandlerFunc, isUiCall, isPwChangeView bool) http.Han
type adminButtonContext struct {
CurrentFile models.FileApiOutput
ActiveUser *models.User
+ Lang languages.Translator
}
// Used internally in templates to create buttons with user context
-func newAdminButtonContext(file models.FileApiOutput, user models.User) adminButtonContext {
- return adminButtonContext{CurrentFile: file, ActiveUser: &user}
+func newAdminButtonContext(file models.FileApiOutput, user models.User, translator languages.Translator) adminButtonContext {
+ return adminButtonContext{CurrentFile: file, ActiveUser: &user, Lang: translator}
+}
+
+type fileRequestContext struct {
+ Request *models.FileRequest
+ Lang languages.Translator
+}
+
+// Used internally in templates to render parts of a file request with the current language
+func newFileRequestContext(request models.FileRequest, translator languages.Translator) fileRequestContext {
+ return fileRequestContext{Request: &request, Lang: translator}
}
// Write a cookie if the user has entered a correct password for a password-protected file
@@ -1191,6 +1259,7 @@ type genericView struct {
ErrorId int
MinPasswordLength int
CustomContent customStatic
+ Lang languages.Translator
}
// A view containing parameters for an oauth error
@@ -1203,6 +1272,7 @@ type oauthErrorView struct {
ErrorProvidedName string
ErrorProvidedMessage string
CustomContent customStatic
+ Lang languages.Translator
}
// A view containing parameters for the public upload page
@@ -1214,4 +1284,5 @@ type publicUploadView struct {
MaxServerSize int
CustomContent customStatic
FileRequest *models.FileRequest
+ Lang languages.Translator
}
diff --git a/internal/webserver/Webserver_test.go b/internal/webserver/Webserver_test.go
index f5e2bf8a..0e95231f 100644
--- a/internal/webserver/Webserver_test.go
+++ b/internal/webserver/Webserver_test.go
@@ -40,6 +40,7 @@ func TestMain(m *testing.M) {
func TestEmbedFs(t *testing.T) {
funcMap := template.FuncMap{
"newAdminButtonContext": newAdminButtonContext,
+ "newFileRequestContext": newFileRequestContext,
}
templates, err := template.New("").Funcs(funcMap).ParseFS(templateFolderEmbedded, "web/templates/*.tmpl")
if err != nil {
diff --git a/internal/webserver/authentication/oauth/Oauth.go b/internal/webserver/authentication/oauth/Oauth.go
index a1ac3402..f1b96f4c 100644
--- a/internal/webserver/authentication/oauth/Oauth.go
+++ b/internal/webserver/authentication/oauth/Oauth.go
@@ -89,11 +89,11 @@ func isLoginRequired(r *http.Request) bool {
func HandlerCallback(w http.ResponseWriter, r *http.Request) {
state, err := r.Cookie(authentication.CookieOauth)
if err != nil {
- errorHandling.RedirectToOAuthErrorPage(w, r, "Parameter state was not provided", err)
+ errorHandling.RedirectToOAuthErrorPage(w, r, "errorpage_oauth_no_state", err)
return
}
if r.URL.Query().Get("state") != state.Value {
- errorHandling.RedirectToOAuthErrorPage(w, r, "Parameter state did not match", err)
+ errorHandling.RedirectToOAuthErrorPage(w, r, "errorpage_oauth_state_mismatch", err)
return
}
@@ -108,18 +108,17 @@ func HandlerCallback(w http.ResponseWriter, r *http.Request) {
oauth2Token, err := config.Exchange(ctx, r.URL.Query().Get("code"))
if err != nil {
- errorHandling.RedirectToOAuthErrorPage(w, r, "Failed to exchange token", err)
+ errorHandling.RedirectToOAuthErrorPage(w, r, "errorpage_oauth_exchange_failed", err)
return
}
userInfo, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token))
if err != nil {
- errorHandling.RedirectToOAuthErrorPage(w, r, "Failed to get user info", err)
+ errorHandling.RedirectToOAuthErrorPage(w, r, "errorpage_oauth_userinfo_failed", err)
return
}
if userInfo.Email == "" {
- errorHandling.RedirectToOAuthErrorPage(w, r, "An empty email address was provided.\nPlease make sure that you have your"+
- " email address set in your authentication user backend.", nil)
+ errorHandling.RedirectToOAuthErrorPage(w, r, "errorpage_oauth_no_email", nil)
return
}
info := authentication.OAuthUserInfo{
@@ -129,7 +128,7 @@ func HandlerCallback(w http.ResponseWriter, r *http.Request) {
}
err = authentication.CheckOauthUserAndRedirect(w, r, info)
if err != nil {
- errorHandling.RedirectToOAuthErrorPage(w, r, "Failed to continue with login: ", err)
+ errorHandling.RedirectToOAuthErrorPage(w, r, "errorpage_oauth_login_failed", err)
}
}
diff --git a/internal/webserver/errorHandling/ErrorHandling.go b/internal/webserver/errorHandling/ErrorHandling.go
index cd437564..1b125b99 100644
--- a/internal/webserver/errorHandling/ErrorHandling.go
+++ b/internal/webserver/errorHandling/ErrorHandling.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/forceu/gokapi/internal/helper"
+ "github.com/forceu/gokapi/internal/languages"
)
var tokens = make(map[string]DisplayedError)
@@ -28,8 +29,25 @@ const (
)
type DisplayedError struct {
- Title string
- Message string
+ // Title is a title that is displayed as-is and is not translated. It is only
+ // used for values that are provided by an external system, e.g. an OIDC
+ // error code. Otherwise, TitleKey should be used.
+ Title string
+ // TitleKey is the translation key of the title. If it is set, it has
+ // priority over Title.
+ TitleKey string
+ // Message is a message that is displayed as-is and is not translated
+ Message string
+ // MessageKey is the translation key of the message. If it is set, it has
+ // priority over Message.
+ MessageKey string
+ // MessageArgs are passed to the translated message and replace the
+ // placeholders {0}, {1}, ...
+ MessageArgs []string
+ // MessageDetail is a technical detail, e.g. the text of an error, that is
+ // appended to the translated message and is never translated itself
+ MessageDetail string
+ // OAuthProviderMessage is the error description that the OIDC provider sent
OAuthProviderMessage string
CardWidth string
ErrorId int
@@ -41,12 +59,42 @@ func (d DisplayedError) IsExpired() bool {
return d.expiry < time.Now().Unix()
}
-func RedirectToErrorPage(w http.ResponseWriter, r *http.Request, errorTitle, errorMessage, cardWidth string) {
+// GetTitle returns the title of the error in the language of the passed translator
+func (d DisplayedError) GetTitle(translator languages.Translator) string {
+ if d.TitleKey == "" {
+ return d.Title
+ }
+ return translator.T(d.TitleKey)
+}
+
+// GetMessage returns the message of the error in the language of the passed translator
+func (d DisplayedError) GetMessage(translator languages.Translator) string {
+ message := d.Message
+ if d.MessageKey != "" {
+ args := make([]any, len(d.MessageArgs))
+ for i, arg := range d.MessageArgs {
+ args[i] = arg
+ }
+ message = translator.Tf(d.MessageKey, args...)
+ }
+ if d.MessageDetail != "" {
+ if message == "" {
+ return d.MessageDetail
+ }
+ return message + " " + d.MessageDetail
+ }
+ return message
+}
+
+// RedirectToErrorPage redirects to an error page that displays a translated title
+// and message. The optional detail is appended to the message and is not translated.
+func RedirectToErrorPage(w http.ResponseWriter, r *http.Request, titleKey, messageKey, messageDetail, cardWidth string) {
result := DisplayedError{
- Title: errorTitle,
- Message: errorMessage,
- expiry: time.Now().Add(ttl).Unix(),
- CardWidth: cardWidth,
+ TitleKey: titleKey,
+ MessageKey: messageKey,
+ MessageDetail: messageDetail,
+ expiry: time.Now().Add(ttl).Unix(),
+ CardWidth: cardWidth,
}
redirectToError(w, r, result)
}
@@ -64,10 +112,11 @@ func RedirectGenericErrorPage(w http.ResponseWriter, r *http.Request, genericTyp
cardWidth = WidthWide
default:
redirectToError(w, r, DisplayedError{
- Title: "Unknown error",
- Message: "Gokapi cannot display this error (error code " + strconv.Itoa(genericType) + ")",
- CardWidth: WidthWide,
- expiry: time.Now().Add(ttl).Unix(),
+ TitleKey: "errorpage_unknown_title",
+ MessageKey: "errorpage_unknown_text",
+ MessageArgs: []string{strconv.Itoa(genericType)},
+ CardWidth: WidthWide,
+ expiry: time.Now().Add(ttl).Unix(),
})
return
}
@@ -81,24 +130,29 @@ func RedirectGenericErrorPage(w http.ResponseWriter, r *http.Request, genericTyp
redirectToError(w, r, result)
}
-func RedirectToOAuthErrorPage(w http.ResponseWriter, r *http.Request, errorMessage string, err error) {
+// RedirectToOAuthErrorPage redirects to an error page for an OIDC error.
+// messageKey is the translation key of the message, the text of the optional
+// error is appended to it and is not translated.
+func RedirectToOAuthErrorPage(w http.ResponseWriter, r *http.Request, messageKey string, err error) {
if r.URL.Query().Get("error") == "access_denied" {
result := DisplayedError{
- Title: "Access denied",
- Message: "The request was denied by the user or authentication provider.",
- expiry: time.Now().Add(ttl).Unix(),
- ErrorId: TypeOAuthNonGeneric,
- IsGeneric: false,
+ TitleKey: "errorpage_access_denied_title",
+ MessageKey: "errorpage_access_denied_text",
+ expiry: time.Now().Add(ttl).Unix(),
+ ErrorId: TypeOAuthNonGeneric,
+ IsGeneric: false,
}
redirectToError(w, r, result)
return
}
+ var detail string
if err != nil {
- errorMessage = errorMessage + " " + err.Error()
+ detail = err.Error()
}
result := DisplayedError{
Title: r.URL.Query().Get("error"),
- Message: errorMessage,
+ MessageKey: messageKey,
+ MessageDetail: detail,
OAuthProviderMessage: r.URL.Query().Get("error_description"),
expiry: time.Now().Add(ttl).Unix(),
ErrorId: TypeOAuthNonGeneric,
@@ -132,9 +186,9 @@ func Get(r *http.Request) DisplayedError {
displayedError, ok := tokens[r.URL.Query().Get("e")]
if !ok {
return DisplayedError{
- Title: "Unknown error ID",
- Message: "Unfortunately, an error occurred and the error message could not be displayed.",
- CardWidth: WidthDefault,
+ TitleKey: "errorpage_unknown_id_title",
+ MessageKey: "errorpage_unknown_id_text",
+ CardWidth: WidthDefault,
}
}
return displayedError
diff --git a/internal/webserver/web/static/js/admin_api.js b/internal/webserver/web/static/js/admin_api.js
index 10e72b27..a03cbf73 100644
--- a/internal/webserver/web/static/js/admin_api.js
+++ b/internal/webserver/web/static/js/admin_api.js
@@ -29,11 +29,11 @@ async function getToken(permission, forceRenewal) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
if (!data.hasOwnProperty("key")) {
- throw new Error(`Invalid response when trying to get token`);
+ throw new Error(t("error_invalid_token_response"));
}
storedTokens.set(permission, {
key: data.key,
@@ -76,7 +76,7 @@ async function apiAuthModify(apiKey, permission, modifier) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
} catch (error) {
console.error("Error in apiAuthModify:", error);
@@ -112,7 +112,7 @@ async function apiAuthFriendlyName(apiKey, newName) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
} catch (error) {
console.error("Error in apiAuthModify:", error);
@@ -146,7 +146,7 @@ async function apiAuthDelete(apiKey) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
} catch (error) {
console.error("Error in apiAuthDelete:", error);
@@ -181,7 +181,7 @@ async function apiAuthCreate() {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
return data;
@@ -235,11 +235,11 @@ async function apiChunkComplete(uuid, filename, filesize, realsize, contenttype,
// Attempt to parse JSON, fallback to text if parsing fails
try {
const errorResponse = await response.json();
- errorMessage = errorResponse.ErrorMessage || `Request failed with status: ${response.status}`;
+ errorMessage = errorResponse.ErrorMessage || t("error_request_failed", response.status);
} catch {
// Handle non-JSON error
const errorText = await response.text();
- errorMessage = errorText || `Request failed with status: ${response.status}`;
+ errorMessage = errorText || t("error_request_failed", response.status);
}
throw new Error(errorMessage);
}
@@ -282,7 +282,7 @@ async function apiFilesReplace(id, newId) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
return data;
@@ -316,7 +316,7 @@ async function apiFilesListById(fileId) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
return data;
@@ -352,7 +352,7 @@ async function apiFilesListDownloadSingle(fileId) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
return data;
@@ -390,7 +390,7 @@ async function apiFilesListDownloadZip(fileIds, filename) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
return data;
@@ -429,7 +429,7 @@ async function apiFilesModify(id, allowedDownloads, expiry, password, originalPw
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
return data;
@@ -467,7 +467,7 @@ async function apiFilesDelete(id, delay) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
} catch (error) {
console.error("Error in apiFilesDelete:", error);
@@ -501,7 +501,7 @@ async function apiFilesRestore(id) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
return data;
@@ -545,7 +545,7 @@ async function apiUserCreate(userName) {
if (response.status == 409) {
throw new Error("duplicate");
}
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
return data;
@@ -584,7 +584,7 @@ async function apiUserModify(userId, permission, modifier) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
} catch (error) {
console.error("Error in apiUserModify:", error);
@@ -620,7 +620,7 @@ async function apiUserChangeRank(userId, newRank) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
} catch (error) {
console.error("Error in apiUserModify:", error);
@@ -654,7 +654,7 @@ async function apiUserDelete(id, deleteFiles) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
} catch (error) {
console.error("Error in apiUserDelete:", error);
@@ -690,7 +690,7 @@ async function apiUserResetPassword(id, generatePw) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
return data;
@@ -728,7 +728,7 @@ async function apiLogSystemStatus() {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
return data;
@@ -762,7 +762,7 @@ async function apiLogResetTraffic() {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
} catch (error) {
console.error("Error in apiLogResetTraffic:", error);
@@ -795,7 +795,7 @@ async function apiLogGet(timestamp) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
return data;
@@ -831,7 +831,7 @@ async function apiLogsDelete(timestamp) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
} catch (error) {
console.error("Error in apiLogsDelete:", error);
@@ -866,7 +866,7 @@ async function apiE2eGet() {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
return await response.text();
} catch (error) {
@@ -903,7 +903,7 @@ async function apiE2eMutexLockUnlock(doUnlock) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
return await response.text();
} catch (error) {
@@ -941,7 +941,7 @@ async function apiE2eStore(content) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
} catch (error) {
console.error("Error in apiE2eStore:", error);
@@ -977,7 +977,7 @@ async function apiURequestDelete(id) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
} catch (error) {
console.error("Error in apiURequestDelete:", error);
@@ -1017,7 +1017,7 @@ async function apiURequestSave(id, name, maxfiles, maxsize, expiry, notes) {
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
- throw new Error(`Request failed with status: ${response.status}`);
+ throw new Error(t("error_request_failed", response.status));
}
const data = await response.json();
return data;
diff --git a/internal/webserver/web/static/js/admin_ui_allPages.js b/internal/webserver/web/static/js/admin_ui_allPages.js
index 39dbbe77..6f8d3497 100644
--- a/internal/webserver/web/static/js/admin_ui_allPages.js
+++ b/internal/webserver/web/static/js/admin_ui_allPages.js
@@ -69,7 +69,7 @@ function downloadFileWithPresign(id) {
apiFilesListDownloadSingle(id)
.then(data => {
if (!data.hasOwnProperty("downloadUrl")) {
- throw new Error("Unable to get presigned key");
+ throw new Error(t("error_presign"));
}
const a = document.createElement('a');
a.href = data.downloadUrl;
@@ -80,7 +80,7 @@ function downloadFileWithPresign(id) {
a.remove();
})
.catch(error => {
- alert("Unable to download: " + error);
+ alert(t("error_unable_download", error));
console.error('Error:', error);
});
}
@@ -89,7 +89,7 @@ function downloadFilesZipWithPresign(ids, filename) {
apiFilesListDownloadZip(ids, filename)
.then(data => {
if (!data.hasOwnProperty("downloadUrl")) {
- throw new Error("Unable to get presigned key");
+ throw new Error(t("error_presign"));
}
const a = document.createElement('a');
a.href = data.downloadUrl;
@@ -100,7 +100,7 @@ function downloadFilesZipWithPresign(ids, filename) {
a.remove();
})
.catch(error => {
- alert("Unable to download: " + error);
+ alert(t("error_unable_download", error));
console.error('Error:', error);
});
}
diff --git a/internal/webserver/web/static/js/admin_ui_api.js b/internal/webserver/web/static/js/admin_ui_api.js
index 40a9fa19..27f7c8d4 100644
--- a/internal/webserver/web/static/js/admin_ui_api.js
+++ b/internal/webserver/web/static/js/admin_ui_api.js
@@ -42,7 +42,7 @@ function changeApiPermission(userId, permission, buttonId) {
indicator.classList.add("perm-notgranted");
}
indicator.classList.remove("perm-processing");
- alert("Unable to set permission: " + error);
+ alert(t("error_unable_set_permission", error));
console.error('Error:', error);
});
}
@@ -59,7 +59,7 @@ function deleteApiKey(apiKey) {
}, 290);
})
.catch(error => {
- alert("Unable to delete API key: " + error);
+ alert(t("error_unable_delete_api_key", error));
console.error('Error:', error);
});
}
@@ -74,7 +74,7 @@ function newApiKey() {
document.getElementById("button-newapi").disabled = false;
})
.catch(error => {
- alert("Unable to create API key: " + error);
+ alert(t("error_unable_create_api_key", error));
console.error('Error:', error);
});
}
@@ -99,7 +99,7 @@ function addFriendlyNameChange(apiKey) {
allowEdit = false;
let newName = input.value;
if (newName == "") {
- newName = "Unnamed key";
+ newName = t("api_unnamed_key");
}
cell.innerText = newName;
@@ -107,7 +107,7 @@ function addFriendlyNameChange(apiKey) {
apiAuthFriendlyName(apiKey, newName)
.catch(error => {
- alert("Unable to save name: " + error);
+ alert(t("error_unable_save_name", error));
console.error('Error:', error);
});
};
@@ -158,14 +158,14 @@ function addRowApi(apiKey, publicId) {
cellButtons.classList.add("newApiKey");
- cellFriendlyName.innerText = "Unnamed key";
+ cellFriendlyName.innerText = t("api_unnamed_key");
cellFriendlyName.id = "friendlyname-" + publicId;
cellFriendlyName.onclick = function() {
addFriendlyNameChange(publicId);
};
cellId.innerText = apiKey;
cellId.classList.add("font-monospace");
- cellLastUsed.innerText = "Never";
+ cellLastUsed.innerText = t("generic_never");
const btnGroup = document.createElement("div");
@@ -177,7 +177,7 @@ function addRowApi(apiKey, publicId) {
const copyButton = document.createElement('button');
copyButton.type = 'button';
copyButton.dataset.clipboardText = apiKey;
- copyButton.title = 'Copy API Key';
+ copyButton.title = t("api_copy_key");
copyButton.className = 'copyurl btn btn-outline-light btn-sm';
copyButton.setAttribute('onclick', 'showToast(1000)');
@@ -188,7 +188,7 @@ function addRowApi(apiKey, publicId) {
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.id = `delete-${publicId}`;
- deleteButton.title = 'Delete';
+ deleteButton.title = t("btn_delete");
deleteButton.className = 'btn btn-outline-danger btn-sm';
deleteButton.setAttribute('onclick', `deleteApiKey('${publicId}')`);
@@ -205,61 +205,61 @@ function addRowApi(apiKey, publicId) {
perm: 'PERM_VIEW',
icon: 'bi-eye',
granted: true,
- title: 'List Uploads'
+ title: t("apiperm_view")
},
{
perm: 'PERM_UPLOAD',
icon: 'bi-file-earmark-plus',
granted: true,
- title: 'Upload'
+ title: t("apiperm_upload")
},
{
perm: 'PERM_EDIT',
icon: 'bi-pencil',
granted: true,
- title: 'Edit Uploads'
+ title: t("apiperm_edit")
},
{
perm: 'PERM_DELETE',
icon: 'bi-trash3',
granted: true,
- title: 'Delete Uploads'
+ title: t("apiperm_delete")
},
{
perm: 'PERM_REPLACE',
icon: 'bi-recycle',
granted: false,
- title: 'Replace Uploads'
+ title: t("apiperm_replace")
},
{
perm: 'PERM_DOWNLOAD',
icon: 'bi-box-arrow-in-down',
granted: false,
- title: 'Download Files'
+ title: t("apiperm_download")
},
{
perm: 'PERM_MANAGE_FILE_REQUESTS',
icon: 'bi-file-earmark-arrow-up',
granted: false,
- title: 'Manage File Requests'
+ title: t("apiperm_file_requests")
},
{
perm: 'PERM_MANAGE_USERS',
icon: 'bi-people',
granted: false,
- title: 'Manage Users'
+ title: t("apiperm_users")
},
{
perm: 'PERM_MANAGE_LOGS',
icon: 'bi-card-list',
granted: false,
- title: 'Manage System Logs'
+ title: t("apiperm_logs")
},
{
perm: 'PERM_API_MOD',
icon: 'bi-sliders2',
granted: false,
- title: 'Manage API Keys'
+ title: t("apiperm_api")
}
];
diff --git a/internal/webserver/web/static/js/admin_ui_filerequest.js b/internal/webserver/web/static/js/admin_ui_filerequest.js
index 5c068d3d..a13c1a3a 100644
--- a/internal/webserver/web/static/js/admin_ui_filerequest.js
+++ b/internal/webserver/web/static/js/admin_ui_filerequest.js
@@ -22,7 +22,7 @@ function deleteFileRequest(requestId) {
}, 290);
})
.catch(error => {
- alert("Unable to delete file request: " + error);
+ alert(t("error_unable_delete_file_request", error));
console.error('Error:', error);
});
}
@@ -50,7 +50,7 @@ function deleteFileFr(id, frId) {
showToastFileDeletionFr(id);
})
.catch(error => {
- alert("Unable to delete file: " + error);
+ alert(t("error_unable_delete_file", error));
console.error('Error:', error);
});
}
@@ -127,7 +127,7 @@ function handleUndoFr(button) {
window.location.reload();
})
.catch(error => {
- alert("Unable to restore file: " + error);
+ alert(t("error_unable_restore_file", error));
console.error('Error:', error);
});
}
@@ -147,7 +147,7 @@ function showDeleteFRequestModal(requestId, requestName, count) {
function newFileRequest() {
loadFileRequestDefaults();
- document.getElementById("m_urequestlabel").innerText = "New File Request";
+ document.getElementById("m_urequestlabel").innerText = t("fr_new_title");
$('#addEditModal').modal('show');
document.getElementById("b_fr_save").onclick = function() {
@@ -207,7 +207,7 @@ function setModalValues(id, name, maxFiles, maxSize, expiry, notes) {
}
checkbox.checked = true;
checkbox.disabled = true;
- checkbox.title = "Only admins can set this to unlimited";
+ checkbox.title = t("fr_admins_only_unlimited");
checkbox.value = "1";
document.getElementById("mi_maxfiles").setAttribute("max", limitMaxFiles);
} else {
@@ -224,7 +224,7 @@ function setModalValues(id, name, maxFiles, maxSize, expiry, notes) {
}
checkbox.checked = true;
checkbox.disabled = true;
- checkbox.title = "Only admins can set this to unlimited";
+ checkbox.title = t("fr_admins_only_unlimited");
checkbox.value = "1";
document.getElementById("mi_maxsize").setAttribute("max", limitMaxSize);
} else {
@@ -272,7 +272,7 @@ function setModalValues(id, name, maxFiles, maxSize, expiry, notes) {
function editFileRequest(id, name, maxFiles, maxSize, expiry, notes) {
setModalValues(id, name, maxFiles, maxSize, expiry, notes);
- document.getElementById("m_urequestlabel").innerText = "Edit File Request";
+ document.getElementById("m_urequestlabel").innerText = t("fr_edit_title");
$('#addEditModal').modal('show');
document.getElementById("b_fr_save").onclick = function() {
@@ -308,7 +308,7 @@ function saveFileRequest() {
insertOrReplaceFileRequest(data);
})
.catch(error => {
- alert("Unable to save file request: " + error);
+ alert(t("error_unable_save_file_request", error));
console.error('Error:', error);
document.getElementById("b_fr_save").disabled = false;
});
@@ -389,7 +389,7 @@ function createFileRequestRow(jsonResult, user) {
// Total size
tr.appendChild(tdText(getReadableSize(jsonResult.totalfilesize)));
// Last upload
- tr.appendChild(tdText(formatTimestampWithNegative(jsonResult.lastupload, "None")));
+ tr.appendChild(tdText(formatTimestampWithNegative(jsonResult.lastupload, t("generic_none"))));
// Expiry
tr.appendChild(tdText(formatFileRequestExpiry(jsonResult.expiry)));
// Optional user column
@@ -410,7 +410,7 @@ function createFileRequestRow(jsonResult, user) {
downloadBtn.id = `download-${jsonResult.id}`;
downloadBtn.type = "button";
downloadBtn.className = "btn btn-outline-light btn-sm";
- downloadBtn.title = "Download all";
+ downloadBtn.title = t("fr_download_all");
if (jsonResult.uploadedfiles == 0) {
downloadBtn.classList.add("disabled");
@@ -424,7 +424,7 @@ function createFileRequestRow(jsonResult, user) {
copyBtn.id = `copy-${jsonResult.id}`;
copyBtn.type = "button";
copyBtn.className = "copyurl btn btn-outline-light btn-sm";
- copyBtn.title = "Copy URL";
+ copyBtn.title = t("btn_copy_url");
copyBtn.setAttribute("data-clipboard-text", publicUrl);
copyBtn.onclick = () =>
showToast(1000);
@@ -437,7 +437,7 @@ function createFileRequestRow(jsonResult, user) {
editBtn.id = `edit-${jsonResult.id}`;
editBtn.type = "button";
editBtn.className = "btn btn-outline-light btn-sm";
- editBtn.title = "Edit request";
+ editBtn.title = t("fr_edit_request");
editBtn.onclick = () =>
editFileRequest(jsonResult.id, jsonResult.name, jsonResult.maxfiles, jsonResult.maxsize, jsonResult.expiry, jsonResult.notes);
@@ -448,7 +448,7 @@ function createFileRequestRow(jsonResult, user) {
deleteBtn.id = `delete-${jsonResult.id}`;
deleteBtn.type = "button";
deleteBtn.className = "btn btn-outline-danger btn-sm";
- deleteBtn.title = "Delete";
+ deleteBtn.title = t("btn_delete");
deleteBtn.onclick = () =>
deleteOrShowModal(jsonResult.id, jsonResult.name, jsonResult.uploadedfiles);
diff --git a/internal/webserver/web/static/js/admin_ui_logs.js b/internal/webserver/web/static/js/admin_ui_logs.js
index 89fa6dba..4298cd15 100644
--- a/internal/webserver/web/static/js/admin_ui_logs.js
+++ b/internal/webserver/web/static/js/admin_ui_logs.js
@@ -14,7 +14,7 @@ function filterLogs(tag) {
function setTrafficInfo(totalTraffic, recordingSince) {
insertReadableSizeTwoOutputs(totalTraffic, 'totalTraffic', 'totalTrafficUnit');
- document.getElementById('cardTraffic').title= "Traffic since "+formatUnixTimestamp(recordingSince);
+ document.getElementById('cardTraffic').title = t("logs_traffic_since", formatUnixTimestamp(recordingSince));
}
function setMemoryUsage(used, total) {
@@ -31,24 +31,30 @@ function setDiskUsage(used, total) {
function formatDuration(seconds) {
+ // "key" is used internally and must not be translated, "label" is displayed
const units = [{
- label: "y",
+ key: "y",
+ label: t("duration_years"),
value: 31536000
},
{
- label: "d",
+ key: "d",
+ label: t("duration_days"),
value: 86400
},
{
- label: "h",
+ key: "h",
+ label: t("duration_hours"),
value: 3600
},
{
- label: "m",
+ key: "m",
+ label: t("duration_minutes"),
value: 60
},
{
- label: "s",
+ key: "s",
+ label: t("duration_seconds"),
value: 1
},
];
@@ -56,8 +62,8 @@ function formatDuration(seconds) {
let startIndex = units.findIndex(unit => seconds >= unit.value);
// If everything is below 1 minute, force start at minutes
- if (startIndex === -1 || units[startIndex].label === "s") {
- startIndex = units.findIndex(u => u.label === "m");
+ if (startIndex === -1 || units[startIndex].key === "s") {
+ startIndex = units.findIndex(u => u.key === "m");
}
const first = units[startIndex];
@@ -129,7 +135,7 @@ async function loadLogs(timestamp) {
} catch (error) {
lastLogUpdate = 0;
console.error("Failed to load logs:", error);
- textarea.value = "Error loading logs. See console for details.";
+ textarea.value = t("logs_load_error");
}
}
@@ -180,7 +186,7 @@ function deleteLogs() {
if (cutoff == "none" || cutoff == "") {
return;
}
- if (!confirm("Do you want to delete the selected logs?")) {
+ if (!confirm(t("logs_confirm_delete"))) {
document.getElementById('deleteLogs').selectedIndex = 0;
return;
}
@@ -209,14 +215,14 @@ function deleteLogs() {
location.reload();
})
.catch(error => {
- alert("Unable to delete logs: " + error);
+ alert(t("error_unable_delete_logs", error));
console.error('Error:', error);
});
}
function resetTrafficStat() {
- if (!confirm("Do you want to reset the traffic statistics?")) {
+ if (!confirm(t("logs_confirm_reset_traffic"))) {
return;
}
@@ -225,7 +231,7 @@ function resetTrafficStat() {
location.reload();
})
.catch(error => {
- alert("Unable to reset stats: " + error);
+ alert(t("error_unable_reset_stats", error));
console.error('Error:', error);
});
}
diff --git a/internal/webserver/web/static/js/admin_ui_upload.js b/internal/webserver/web/static/js/admin_ui_upload.js
index 4232afc3..1aa67865 100644
--- a/internal/webserver/web/static/js/admin_ui_upload.js
+++ b/internal/webserver/web/static/js/admin_ui_upload.js
@@ -39,18 +39,18 @@ function initDropzone() {
console.log(errorMessage);
if (xhr) {
if (xhr.status === 413) {
- showError(file, "File too large to upload. If you are using a reverse proxy, make sure that the allowed body size is at least 70MB.");
+ showError(file, t("upload_error_too_large"));
return;
}
try {
console.log(xhr);
errInfo = JSON.parse(xhr.responseText);
- showError(file, "Error: " + errInfo.ErrorMessage);
+ showError(file, t("error_generic", errInfo.ErrorMessage));
} catch (ignored) {
- showError(file, "Error: " + xhr.responseText);
+ showError(file, t("error_generic", xhr.responseText));
}
} else {
- showError(file, "Error: " + errorMessage);
+ showError(file, t("error_generic", errorMessage));
}
});
this.on("uploadprogress", function(file, progress, bytesSent) {
@@ -103,7 +103,7 @@ function initDropzone() {
window.addEventListener('beforeunload', (event) => {
if (isUploading) {
- event.returnValue = 'Upload is still in progress. Do you want to close this page?';
+ event.returnValue = t("upload_close_warning");
}
});
}
@@ -227,7 +227,7 @@ function sendChunkComplete(file, done) {
done();
let progressText = document.getElementById(`us-progress-info-${file.upload.uuid}`);
if (progressText != null)
- progressText.innerText = "In Queue...";
+ progressText.innerText = t("status_in_queue");
})
.catch(error => {
console.error('Error:', error);
@@ -312,26 +312,26 @@ function parseProgressStatus(eventData) {
let text;
switch (eventData.upload_status) {
case 0:
- text = "Processing file...";
+ text = t("status_processing");
break;
case 1:
- text = "Saving file...";
+ text = t("status_saving");
break;
case 2:
- text = "Finalising...";
+ text = t("status_finalising");
requestFileInfo(eventData.file_id, eventData.chunk_id);
break;
case 3:
- text = "Error";
+ text = t("status_error");
let file = dropzoneGetFile(eventData.chunk_id);
if (eventData.error_message == "")
- eventData.error_message = "Server Error";
+ eventData.error_message = t("status_server_error");
if (file != null) {
dropzoneUploadError(file, eventData.error_message);
}
return;
default:
- text = "Unknown status";
+ text = t("status_unknown");
break;
}
document.getElementById(`us-progress-info-${eventData.chunk_id}`).innerText = text;
@@ -354,7 +354,7 @@ function editFile() {
let allowedDownloads = document.getElementById('mi_edit_down').value;
let expiryTimestamp = document.getElementById('mi_edit_expiry').value;
let password = document.getElementById('mi_edit_pw').value;
- let originalPassword = (password === '(unchanged)');
+ let originalPassword = (password === t("editmodal_password_unchanged"));
if (!document.getElementById('mc_download').checked) {
allowedDownloads = 0;
@@ -385,13 +385,13 @@ function editFile() {
location.reload();
})
.catch(error => {
- alert("Unable to edit file: " + error);
+ alert(t("error_unable_edit_file", error));
console.error('Error:', error);
button.disabled = false;
});
})
.catch(error => {
- alert("Unable to edit file: " + error);
+ alert(t("error_unable_edit_file", error));
console.error('Error:', error);
button.disabled = false;
});
@@ -434,7 +434,7 @@ function showEditModal(filename, id, downloads, expiry, password, unlimitedown,
}
if (password) {
- document.getElementById("mi_edit_pw").value = "(unchanged)";
+ document.getElementById("mi_edit_pw").value = t("editmodal_password_unchanged");
document.getElementById("mi_edit_pw").disabled = false;
document.getElementById("mc_password").checked = true;
} else {
@@ -455,9 +455,9 @@ function showEditModal(filename, id, downloads, expiry, password, unlimitedown,
}
} else {
document.getElementById("mc_replace").disabled = true;
- document.getElementById("mc_replace").title = "Replacing content is not available for end-to-end encrypted files";
- selectReplace.add(new Option("Unavailable", 0));
- selectReplace.title = "Replacing content is not available for end-to-end encrypted files";
+ document.getElementById("mc_replace").title = t("editmodal_replace_e2e_notice");
+ selectReplace.add(new Option(t("editmodal_replace_unavailable"), 0));
+ selectReplace.title = t("editmodal_replace_e2e_notice");
selectReplace.value = "0";
}
} else {
@@ -470,7 +470,7 @@ function showEditModal(filename, id, downloads, expiry, password, unlimitedown,
}
function selectTextForPw(input) {
- if (input.value === "(unchanged)") {
+ if (input.value === t("editmodal_password_unchanged")) {
input.setSelectionRange(0, input.value.length);
}
}
@@ -508,7 +508,7 @@ function deleteFile(id) {
notifyWorker({ type: "fileDeleted", id: id });
})
.catch(error => {
- alert("Unable to delete file: " + error);
+ alert(t("error_unable_delete_file", error));
console.error('Error:', error);
});
}
@@ -755,13 +755,13 @@ function addRow(item) {
cellDownloadCount.id = "cell-downloads-" + item.Id;
cellFileSize.innerText = item.Size;
if (item.UnlimitedDownloads) {
- cellRemainingDownloads.innerText = "Unlimited";
+ cellRemainingDownloads.innerText = t("generic_unlimited");
} else {
cellRemainingDownloads.innerText = item.DownloadsRemaining;
cellRemainingDownloads.id = "cell-downloadsRemaining-" + item.Id;
}
if (item.UnlimitedTime) {
- cellStoredUntil.innerText = "Unlimited";
+ cellStoredUntil.innerText = t("generic_unlimited");
} else {
cellStoredUntil.innerText = formatUnixTimestamp(item.ExpireAt);
}
@@ -779,7 +779,7 @@ function addRow(item) {
if (item.IsPasswordProtected === true) {
const icon = document.createElement('i');
icon.className = 'bi bi-key';
- icon.title = 'Password protected';
+ icon.title = t("filetable_password_protected");
cellUrl.appendChild(document.createTextNode(' '));
cellUrl.appendChild(icon);
}
@@ -815,12 +815,12 @@ function createButtonGroup(item) {
copyUrlBtn.className = 'copyurl btn btn-outline-light btn-sm';
copyUrlBtn.dataset.clipboardText = item.UrlDownload;
copyUrlBtn.id = 'url-button-' + item.Id;
- copyUrlBtn.title = 'Copy URL';
+ copyUrlBtn.title = t("btn_copy_url");
const copyIcon = document.createElement('i');
copyIcon.className = 'bi bi-copy';
copyUrlBtn.appendChild(copyIcon);
- copyUrlBtn.appendChild(document.createTextNode(' URL'));
+ copyUrlBtn.appendChild(document.createTextNode(' ' + t("btn_url")));
copyUrlBtn.addEventListener('click', () => {
showToast(1000);
@@ -844,14 +844,14 @@ function createButtonGroup(item) {
const aDr1 = document.createElement("a");
if (item.UrlHotlink !== "") {
aDr1.className = "dropdown-item copyurl";
- aDr1.title = "Copy hotlink";
+ aDr1.title = t("btn_copy_hotlink");
aDr1.style.cursor = "pointer";
aDr1.setAttribute("data-clipboard-text", item.UrlHotlink);
aDr1.onclick = () => showToast(1000);
- aDr1.innerHTML = ` Hotlink`;
+ aDr1.innerHTML = ' ' + t("btn_hotlink");
} else {
aDr1.className = "dropdown-item";
- aDr1.innerText = "Hotlink not available";
+ aDr1.innerText = t("btn_hotlink_unavailable");
}
liDr1.appendChild(aDr1);
dropdown1.appendChild(liDr1);
@@ -861,7 +861,7 @@ function createButtonGroup(item) {
const btnShare = document.createElement("button");
btnShare.type = "button";
btnShare.className = "btn btn-outline-light btn-sm";
- btnShare.title = "Share";
+ btnShare.title = t("btn_share");
btnShare.onclick = () => shareUrl(event, item.Id);
// For some reason bi-share does not always show up, using the svg fixes it
btnShare.innerHTML = `