mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2025-10-26 11:27:18 +00:00
Pull request 2475: AG-46192-lang-validation
Squashed commit of the following: commit 4129832c67272fa28c2cee0caeb0859900c9fb28 Merge:c3db487b9b4f1ab280Author: Igor Lobanov <i.lobanov@adguard.com> Date: Mon Sep 22 11:06:22 2025 +0200 Merge branch 'master' into AG-46192-lang-validation # Conflicts: # CHANGELOG.md commitc3db487b9dAuthor: Ildar Kamalov <ik@adguard.com> Date: Fri Sep 19 16:12:06 2025 +0300 ADG-10728 remove language update request on page reload commit20f3a3057bAuthor: Stanislav Chzhen <s.chzhen@adguard.com> Date: Tue Sep 16 15:47:50 2025 +0300 home: imp naming commit942c28f4c0Author: Stanislav Chzhen <s.chzhen@adguard.com> Date: Tue Sep 16 13:15:28 2025 +0300 all: add tests commita871d51341Author: Stanislav Chzhen <s.chzhen@adguard.com> Date: Mon Sep 15 18:09:30 2025 +0300 home: lang validation
This commit is contained in:
parent
b4f1ab2808
commit
2a81beeb46
@ -28,6 +28,7 @@ NOTE: Add new changes BELOW THIS COMMENT.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Excessive configuration file overwrites when visiting the Web UI and a non-empty `language` is set.
|
||||
- Lowered the severity of log messages for failed deletion of old filter files ([#7964]).
|
||||
|
||||
[#7964]: https://github.com/AdguardTeam/AdGuardHome/issues/7964
|
||||
|
||||
@ -26,7 +26,7 @@ import { getLogsUrlParams, setHtmlLangAttr, setUITheme } from '../../helpers/hel
|
||||
|
||||
import Header from '../Header';
|
||||
|
||||
import { changeLanguage, getDnsStatus, getTimerStatus } from '../../actions';
|
||||
import { getDnsStatus, getTimerStatus } from '../../actions';
|
||||
|
||||
import Dashboard from '../../containers/Dashboard';
|
||||
import SetupGuide from '../../containers/SetupGuide';
|
||||
@ -134,16 +134,12 @@ const App = () => {
|
||||
}, []);
|
||||
|
||||
const setLanguage = () => {
|
||||
if (!processing) {
|
||||
if (language) {
|
||||
i18n.changeLanguage(language);
|
||||
setHtmlLangAttr(language);
|
||||
}
|
||||
if (processing || !language) {
|
||||
return;
|
||||
}
|
||||
|
||||
i18n.on('languageChanged', (lang) => {
|
||||
dispatch(changeLanguage(lang));
|
||||
});
|
||||
i18n.changeLanguage(language);
|
||||
setHtmlLangAttr(language);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -13,7 +13,7 @@ import './Select.css';
|
||||
|
||||
import { setHtmlLangAttr, setUITheme } from '../../helpers/helpers';
|
||||
|
||||
import { changeTheme } from '../../actions';
|
||||
import { changeLanguage, changeTheme } from '../../actions';
|
||||
import { RootState } from '../../initialState';
|
||||
|
||||
const linksData = [
|
||||
@ -46,10 +46,10 @@ const Footer = () => {
|
||||
return today.getFullYear();
|
||||
};
|
||||
|
||||
const changeLanguage = (event: any) => {
|
||||
const { value } = event.target;
|
||||
i18n.changeLanguage(value);
|
||||
setHtmlLangAttr(value);
|
||||
const onLanguageChange = (language: string) => {
|
||||
i18n.changeLanguage(language);
|
||||
setHtmlLangAttr(language);
|
||||
dispatch(changeLanguage(language));
|
||||
};
|
||||
|
||||
const onThemeChange = (value: any) => {
|
||||
@ -143,7 +143,7 @@ const Footer = () => {
|
||||
<select
|
||||
className="form-control select select--language"
|
||||
value={i18n.language}
|
||||
onChange={changeLanguage}>
|
||||
onChange={(e) => onLanguageChange(e.target.value)}>
|
||||
{Object.keys(LANGUAGES).map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{LANGUAGES[lang]}
|
||||
|
||||
@ -721,6 +721,14 @@ func validateConfig(ctx context.Context, l *slog.Logger) (err error) {
|
||||
l.WarnContext(ctx, "no users in the configuration file; authentication is disabled")
|
||||
}
|
||||
|
||||
if config.Language != "" && !allowedLanguages.Has(config.Language) {
|
||||
l.WarnContext(ctx, "unsupported language", "lang", config.Language)
|
||||
|
||||
// Clear the language so the frontend can use the client's browser
|
||||
// language.
|
||||
config.Language = ""
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -99,16 +99,26 @@ func (web *webAPI) handlePutProfile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
theme := profileReq.Theme
|
||||
|
||||
changed := false
|
||||
func() {
|
||||
config.Lock()
|
||||
defer config.Unlock()
|
||||
|
||||
if config.Language == lang && config.Theme == theme {
|
||||
web.logger.DebugContext(ctx, "updating profile; no changes")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
changed = true
|
||||
config.Language = lang
|
||||
config.Theme = theme
|
||||
web.logger.InfoContext(ctx, "profile updated", "lang", lang, "theme", theme)
|
||||
}()
|
||||
|
||||
web.confModifier.Apply(ctx)
|
||||
if changed {
|
||||
web.confModifier.Apply(ctx)
|
||||
}
|
||||
|
||||
aghhttp.OK(w)
|
||||
}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
package home
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@ -10,6 +14,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/AdguardTeam/AdGuardHome/internal/agh"
|
||||
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
|
||||
"github.com/AdguardTeam/AdGuardHome/internal/aghtest"
|
||||
"github.com/AdguardTeam/golibs/errors"
|
||||
"github.com/AdguardTeam/golibs/httphdr"
|
||||
"github.com/AdguardTeam/golibs/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -117,3 +125,123 @@ func TestWeb_HandleGetProfile(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestWeb_HandlePutProfile(t *testing.T) {
|
||||
storeGlobals(t)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
globalContext.mux = mux
|
||||
|
||||
isConfigChanged := false
|
||||
confModifier := &aghtest.ConfigModifier{
|
||||
OnApply: func(_ context.Context) { isConfigChanged = true },
|
||||
}
|
||||
|
||||
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
||||
web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, nil, confModifier, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
globalContext.web = web
|
||||
|
||||
var (
|
||||
dataValid = errors.Must(json.Marshal(&profileJSON{
|
||||
Language: "en",
|
||||
Theme: "auto",
|
||||
}))
|
||||
|
||||
dataInvalidLang = errors.Must(json.Marshal(&profileJSON{
|
||||
Language: "invalid_lang",
|
||||
Theme: "auto",
|
||||
}))
|
||||
|
||||
dataInvalidTheme = errors.Must(json.Marshal(&profileJSON{
|
||||
Language: "en",
|
||||
Theme: "invalid_theme",
|
||||
}))
|
||||
)
|
||||
|
||||
testCases := []struct {
|
||||
req *http.Request
|
||||
name string
|
||||
wantBody string
|
||||
wantCode int
|
||||
}{{
|
||||
req: newProfileUpdateRequest(http.MethodPut, dataValid, true),
|
||||
name: "basic",
|
||||
wantBody: "OK\n",
|
||||
wantCode: http.StatusOK,
|
||||
}, {
|
||||
req: newProfileUpdateRequest(http.MethodGet, dataValid, true),
|
||||
name: "invalid_method",
|
||||
wantBody: "only method PUT is allowed\n",
|
||||
wantCode: http.StatusMethodNotAllowed,
|
||||
}, {
|
||||
req: newProfileUpdateRequest(http.MethodPut, dataValid, false),
|
||||
name: "invalid_content_type",
|
||||
wantBody: "only content-type application/json is allowed\n",
|
||||
wantCode: http.StatusUnsupportedMediaType,
|
||||
}, {
|
||||
req: newProfileUpdateRequest(http.MethodPut, nil, false),
|
||||
name: "empty_body",
|
||||
wantBody: "reading req: EOF\n",
|
||||
wantCode: http.StatusBadRequest,
|
||||
}, {
|
||||
req: newProfileUpdateRequest(http.MethodPut, dataInvalidLang, true),
|
||||
name: "invalid_language",
|
||||
wantBody: `unknown language: "invalid_lang"` + "\n",
|
||||
wantCode: http.StatusBadRequest,
|
||||
}, {
|
||||
req: newProfileUpdateRequest(http.MethodPut, dataInvalidTheme, true),
|
||||
name: "invalid_theme",
|
||||
wantBody: `reading req: invalid theme "invalid_theme", ` +
|
||||
`supported: "auto", "dark", "light"` + "\n",
|
||||
wantCode: http.StatusBadRequest,
|
||||
}}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
mux.ServeHTTP(w, tc.req)
|
||||
assert.Equal(t, tc.wantCode, w.Code)
|
||||
assert.Equal(t, tc.wantBody, w.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
require.True(t, t.Run("single_config_update", func(t *testing.T) {
|
||||
isConfigChanged = false
|
||||
config.Language = ""
|
||||
config.Theme = ""
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
mux.ServeHTTP(w, newProfileUpdateRequest(http.MethodPut, dataValid, true))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
assert.True(t, isConfigChanged)
|
||||
|
||||
isConfigChanged = false
|
||||
|
||||
mux.ServeHTTP(w, newProfileUpdateRequest(http.MethodPut, dataValid, true))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
assert.False(t, isConfigChanged)
|
||||
}))
|
||||
}
|
||||
|
||||
// newProfileUpdateRequest builds an *http.Request for the profile update
|
||||
// endpoint. If body is non-nil, it is used as the request body. If setCT is
|
||||
// true, the Content-Type header is set to application/json.
|
||||
func newProfileUpdateRequest(method string, body []byte, setCT bool) (req *http.Request) {
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
r = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(method, "/control/profile/update", r)
|
||||
if setCT {
|
||||
req.Header.Set(httphdr.ContentType, aghhttp.HdrValApplicationJSON)
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user